Widget-Prognose: intuitive "Ø X T nach Rechnung" + rotes Datum statt Text [deploy]
All checks were successful
Deploy mahnung / deploy (push) Successful in 13s

Eddy-Feedback: Die Differenz-zur-Faelligkeit (z.B. -13.5 T) war unintuitiv
(Kopfrechnen noetig) und "(spaeter als ueblich)" hat die Widget-Spalte
gesprengt.

- Prognosedatum jetzt am RECHNUNGSDATUM verankert: datef + Oe Tage nach
  Rechnungseingang (avg_pay). Das ist die klassische Days-to-Pay-Kennzahl,
  vermeidet Prognosen vor dem Rechnungsdatum und ist direkt lesbar.
- Sichtbare Zahl = "Oe X T nach Rechnung" (positiv) statt Differenz zur
  Faelligkeit. Ampel-Icon bleibt auf der Faelligkeits-Skala (diff) -> weiter
  Paritaet zur Zahlungsverhalten-Box der Kundenkarte.
- "(spaeter als ueblich)" als Text entfernt: bei verstrichener Prognose
  wird stattdessen nur das DATUM rot gefaerbt (Zeile bleibt schmal),
  Erklaerung steckt im Tooltip.
- getZahlprognose liefert zusaetzlich avg_pay; buildPrognoseCell bekommt
  das Rechnungsdatum durchgereicht. Neuer Lang-Key MahnungProgNachRechnung,
  Tooltip umformuliert.

Lokal gegen die Test-DB verifiziert (Datum aus datef+avg_pay, Rot nur bei
verstrichener Prognose, Zukunfts-Rechnung ohne Rot, php -l sauber).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-07-19 11:48:08 +02:00
parent 4997a78599
commit fdd2b17ab4
5 changed files with 49 additions and 30 deletions

View file

@ -3,12 +3,12 @@
## [Unreleased]
### Widget (box_mahnung_offen)
- **Neue Spalte „Vsl. Zahlung" (Zahlungsprognose)**: Je offener Rechnung wird aus dem bisherigen Zahlungsverhalten des Kunden prognostiziert, wann er die Rechnung voraussichtlich zahlt (`Fälligkeit + round(Ø-Verzug)`), inkl. Ampel-Icon + Bewertungsstufe + Ø-Verzug + Stichprobengröße `n`.
- **Skala 1:1 aus dem BuchhaltungsWidget** übernommen (KB #886), damit die Ampel zur „Zahlungsverhalten"-Box auf der Kundenkarte passt: `diff = Ø(ZahlungRechnungsdatum) Ø(FälligkeitRechnungsdatum)` = Ø Tage nach Fälligkeit; Stufen ≤5 Vorbildlich ★ / ≤0 Pünktlich ✓ / ≤7 Spätzahler ⚠ / ≤14 Verspätet ⏱ / >14 Problematisch ✗ (gleiche Farben `#28a745/#ffc107/#fd7e14/#dc3545`).
- **Neue Spalte „Vsl. Zahlung" (Zahlungsprognose)**: Je offener Rechnung wird prognostiziert, wann der Kunde sie voraussichtlich zahlt — als **konkretes Datum** plus der direkt lesbaren Kennzahl **„Ø X T nach Rechnung"** (Days-to-Pay). Zweizeilige Zelle: Ampel-Icon + Prognosedatum / darunter `Ø <Tage> nach Rechnung · n=<Stichprobe>`.
- **Prognose am Rechnungsdatum verankert**: `Prognosedatum = datef + Ø(Zahlung Rechnungsdatum)`. Das ist die intuitive „zahlt X Tage nach Rechnungseingang"-Zahl (positiv, kein Kopfrechnen wie bei einer Differenz zur Fälligkeit) und verhindert Prognosen **vor** dem Rechnungsdatum. Fallback auf `Fälligkeit + diff`, falls `datef` fehlt.
- **Ampel-Skala 1:1 aus dem BuchhaltungsWidget** übernommen (KB #886), damit das Icon zur „Zahlungsverhalten"-Box auf der Kundenkarte passt: Bewertung über `diff = Ø Tage nach Fälligkeit`; Stufen ≤5 Vorbildlich ★ / ≤0 Pünktlich ✓ / ≤7 Spätzahler ⚠ / ≤14 Verspätet ⏱ / >14 Problematisch ✗ (Farben `#28a745/#ffc107/#fd7e14/#dc3545`). Icon = Pünktlichkeit (relativ zur Fälligkeit), sichtbare Zahl = Tage nach Rechnung — beides wie auf der Kundenkarte.
- **Berechnung als self-contained Kopie** (`getZahlprognose()`, 1 Query je Kunde/Request gecacht) — **keine** Laufzeit-Abhängigkeit auf das BuchhaltungsWidget. Filter identisch KB #886: `type IN (0,1,5)`, `fk_statut=2 + paye=1`, `date_lim_reglement IS NOT NULL`, `DATEDIFF`, `entity`.
- Prognosedatum bereits verstrichen, Rechnung aber noch offen → Zusatz „(später als üblich)" — der Kunde zahlt langsamer als sein eigenes Muster (Frühwarnung; greift ggf. schon **vor** der formellen Fälligkeit, ist also bewusst nicht mit „überfällig" beschriftet).
- Prognosedatum + Ø-Verzug aus dem **exakten** Mittelwert (eine Nachkommastelle), damit die Zahl 1:1 zur „Differenz" auf der Kundenkarte passt (z.B. 3.1 T).
- Mindest-Stichprobe fürs Datum via Konstante `MAHNUNG_PROGNOSE_MIN_N` (**Default 1** = Parität zur Kundenkarten-Box, die ebenfalls ab der 1. bezahlten Rechnung bewertet), darunter „keine Historie". Neue Lang-Keys `MahnungProg*` (de_DE + en_US).
- **Verstrichene Prognose** (Rechnung noch offen, zahlt langsamer als sein Muster) → das **Prognosedatum wird rot** eingefärbt statt mit Zusatztext beschriftet (hält die Widget-Spalte schmal); die Erläuterung („später als üblich") steht im Tooltip.
- Mindest-Stichprobe via Konstante `MAHNUNG_PROGNOSE_MIN_N` (**Default 1** = Parität zur Kundenkarten-Box, die ebenfalls ab der 1. bezahlten Rechnung bewertet), darunter „keine Historie". Neue Lang-Keys `MahnungProg*` (de_DE + en_US).
- **Summenzeile Netto + Brutto**: Die `liste_total`-Zeile zeigt jetzt zweizeilig **Netto** (`SUM(f.total_ht)`) und **Brutto** (`SUM(f.total_ttc)`). Der Bruttobetrag kommt direkt aus `f.total_ttc` der Rechnung und wird **nicht** aus dem Netto hochgerechnet — so bleiben Steuer-Sonderregelungen (Reverse-Charge §13b, Steuerbefreiung, Kleinunternehmer §19 UStG) korrekt. Einzelzeilen zeigen weiterhin nur Netto. Neue Lang-Keys `MahnungBoxNetto`/`MahnungBoxBrutto` (de_DE + en_US).
- **Zähler im Kopf**: Der Badge im Widget-Kopf zeigt die tatsächliche Gesamtzahl offener Rechnungen (verlinkt auf die gefilterte Rechnungsliste). Titel-Lang-Key ohne `(%s)`.
- **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 (korrekter Zähler), rendert aber nur `MAHNUNG_BOX_MAXLINES` Zeilen + eine `...`-Überlaufzeile.

View file

@ -31,7 +31,7 @@ Dolibarr Custom-Modul: 3-stufiges Mahnwesen nach BGB §288 + Versand-Tracking +
- **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`). `diff = Ø(ZahlungRechnungsdatum) Ø(FälligkeitRechnungsdatum)`; Prognosedatum = `Fälligkeit + diff` (exakt, aus dem Timestamp); Mindest-`n` via `MAHNUNG_PROGNOSE_MIN_N` (**Default 1** = Parität zur Kundenkarten-Box). Prognose verstrichen + Rechnung offen → „(später als üblich)" (NICHT „überfällig" — greift ggf. schon vor formeller Fälligkeit). 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).
- **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).
## 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)

View file

@ -147,6 +147,7 @@ class box_mahnung_offen extends ModeleBoxes
$objp = $this->db->fetch_object($result);
$datelimit = $this->db->jdate($objp->datelimit);
$dateinvoice = $this->db->jdate($objp->date);
$facturestatic->id = $objp->facid;
$facturestatic->ref = $objp->ref;
@ -223,7 +224,7 @@ class box_mahnung_offen extends ModeleBoxes
// Spalte 5: Voraussichtliche Zahlung (Prognose aus dem Zahlungsverhalten)
$this->info_box_contents[$line][] = array(
'td' => 'class="center nowraponall"',
'text' => $this->buildPrognoseCell((int) $objp->socid, $datelimit),
'text' => $this->buildPrognoseCell((int) $objp->socid, $datelimit, $dateinvoice),
'asis' => 1,
);
@ -359,6 +360,9 @@ class box_mahnung_offen extends ModeleBoxes
if ($o && (int) $o->invoice_count > 0) {
$out = array(
'count' => (int) $o->invoice_count,
// avg_pay = Ø Tage nach Rechnungseingang (intuitive "Days to Pay"-Zahl).
'avg_pay' => (float) $o->avg_payment_days,
// diff = Ø Tage nach Fälligkeit (nur für die Ampel-Bewertung, KB #886).
'diff' => (float) $o->avg_payment_days - (float) $o->avg_due_days,
);
}
@ -394,14 +398,20 @@ class box_mahnung_offen extends ModeleBoxes
/**
* Baut die HTML-Zelle "Vsl. Zahlung": Ampel-Icon + Prognosedatum + Kurzstatistik.
* Prognosedatum = Fälligkeit + round(diff). Prognose bereits vorbei, Rechnung aber
* noch offen Zusatz "(überfällig)" (Kunde zahlt schlechter als sonst).
*
* Prognose = Rechnungsdatum (datef) + Ø Tage nach Rechnungseingang (avg_pay)
* die intuitive "Days to Pay"-Kennzahl. Verstrichenes Prognosedatum bei noch
* offener Rechnung Datum wird rot eingefärbt (kompakt, Erklärung im Tooltip),
* KEIN zusätzlicher Text (würde die Widget-Spalte zu breit machen).
* Ampel-Icon bleibt auf der Fälligkeits-Skala (diff), damit es zur
* "Zahlungsverhalten"-Box der Kundenkarte passt.
*
* @param int $socid
* @param int $datelimit Fälligkeit als Unix-Timestamp (jdate)
* @param int $datelimit Fälligkeit als Unix-Timestamp (jdate) Fallback-Anker
* @param int $dateinvoice Rechnungsdatum (datef) als Unix-Timestamp (jdate)
* @return string HTML (wird 'asis' gerendert)
*/
private function buildPrognoseCell($socid, $datelimit)
private function buildPrognoseCell($socid, $datelimit, $dateinvoice)
{
global $langs;
@ -422,27 +432,34 @@ class box_mahnung_offen extends ModeleBoxes
$rating = $this->prognoseRating($prog['diff']);
$label = $langs->transnoentities($rating['labelkey']);
// Prognosedatum aus dem EXAKTEN Ø-Verzug (gleiche Zahl wie "Differenz" auf der
// Kundenkarte); Anzeige mit einer Nachkommastelle, damit z.B. 3.1 exakt passt.
// Prognosedatum = Rechnungsdatum + Ø Tage nach Rechnungseingang. Verankerung am
// Rechnungsdatum (statt Fälligkeit) ist intuitiv und vermeidet Prognosen VOR dem
// Rechnungsdatum. Fallback auf Fälligkeit + diff, falls datef fehlt.
if ((int) $dateinvoice > 0) {
$progTs = (int) round((int) $dateinvoice + $prog['avg_pay'] * 86400);
} else {
$progTs = (int) round((int) $datelimit + $prog['diff'] * 86400);
}
$progDate = dol_print_date($progTs, 'day', 'tzuserrel');
$avg = sprintf('%+.1f', $prog['diff']); // Vorzeichen: + nach Fälligkeit, davor
$nachRe = sprintf('%.1f', $prog['avg_pay']); // Ø Tage nach Rechnungseingang (i.d.R. positiv)
// Prognosedatum bereits verstrichen, Rechnung aber noch offen → der Kunde zahlt
// langsamer als sein eigenes Muster. NICHT dasselbe wie "überfällig" (kann schon
// vor der formellen Fälligkeit greifen) — daher eigene Formulierung.
$hint = '';
// Prognosedatum verstrichen, Rechnung noch offen → zahlt langsamer als sein Muster.
// Kompakt: nur das Datum rot färben + Tooltip-Zusatz (kein Extra-Text in der Zelle).
$dateStyle = '';
$tipExtra = '';
if ($progTs < dol_now()) {
$hint = ' <span style="color:#dc3545;">('.dol_escape_htmltag($langs->transnoentities('MahnungProgSpaeterAlsUeblich')).')</span>';
$dateStyle = ' style="color:#dc3545;"';
$tipExtra = ' — '.$langs->transnoentities('MahnungProgSpaeterAlsUeblich');
}
// Parameter direkt an trans übergeben (macht das %s-sprintf selbst).
$tooltip = $langs->transnoentities('MahnungProgTooltip', $progDate, $avg, (string) $prog['count']);
// Voller Kontext im Tooltip (Parameter direkt an trans → macht das %s-sprintf selbst).
$tooltip = $label.': '.$langs->transnoentities('MahnungProgTooltip', $progDate, $nachRe, (string) $prog['count']).$tipExtra;
$html = '<span title="'.dol_escape_htmltag($tooltip).'">';
$html .= '<span style="color:'.$rating['color'].';">'.$rating['icon'].'</span> ~'.$progDate.$hint;
$html .= '<br><span class="opacitymedium small">'.dol_escape_htmltag($label)
.' &middot; &#216; '.$avg.'&nbsp;T &middot; n='.((int) $prog['count']).'</span>';
$html .= '<span style="color:'.$rating['color'].';">'.$rating['icon'].'</span> ';
$html .= '<span'.$dateStyle.'>~'.$progDate.'</span>';
$html .= '<br><span class="opacitymedium small">&#216; '.$nachRe.'&nbsp;'
.dol_escape_htmltag($langs->transnoentities('MahnungProgNachRechnung')).' &middot; n='.((int) $prog['count']).'</span>';
$html .= '</span>';
return $html;

View file

@ -221,7 +221,8 @@ MahnungProgProblematisch = Problematisch
MahnungProgKeineHistorie = keine Historie
MahnungProgKeineHistorieTip = Zu wenig bezahlte Rechnungen für eine belastbare Zahlungsprognose
MahnungProgSpaeterAlsUeblich = später als üblich
MahnungProgTooltip = Voraussichtlich am %s (im Schnitt %s Tage zur Fälligkeit, %s bezahlte Rechnungen)
MahnungProgNachRechnung = T nach Rechnung
MahnungProgTooltip = voraussichtlich am %s — zahlt Ø %s Tage nach Rechnungseingang (%s bezahlte Rechnungen)
#
# Dokumentenmodelle

View file

@ -346,7 +346,8 @@ MahnungProgProblematisch = Problematic
MahnungProgKeineHistorie = no history
MahnungProgKeineHistorieTip = Too few paid invoices for a reliable payment forecast
MahnungProgSpaeterAlsUeblich = later than usual
MahnungProgTooltip = Expected on %s (on average %s days relative to due date, %s paid invoices)
MahnungProgNachRechnung = d after invoice
MahnungProgTooltip = expected on %s — pays on average %s days after invoice date (%s paid invoices)
MahnungVorschlagStufeNichtKonfiguriert = Stage 1 not configured
MahnungVorschlagFristNichtErreicht = Stage 1 deadline (%s days) not yet reached (overdue %s days)
MahnungVorschlagAlleStufenAusgeschoepft = All dunning stages exhausted (last stage %s)