Widget: Zahlungsprognose-Spalte (Skala aus BuchhaltungsWidget/KB #886) [deploy]
All checks were successful
Deploy mahnung / deploy (push) Successful in 13s

Neue Spalte "Vsl. Zahlung" in box_mahnung_offen: je offener Rechnung
Prognose (Faelligkeit + round(Oe-Verzug)) aus dem bisherigen Zahlungs-
verhalten des Kunden, mit Ampel-Icon + Bewertungsstufe + Tooltip.

- Skala 1:1 aus BuchhaltungsWidget uebernommen (KB #886), damit die
  Ampel zur "Zahlungsverhalten"-Box auf der Kundenkarte passt: 5 Stufen
  Vorbildlich/Puenktlich/Spaetzahler/Verspaetet/Problematisch anhand
  Oe-Tage nach Faelligkeit, gleiche Schwellen + Farben.
- Berechnung als self-contained Kopie (getZahlprognose, 1 Query je
  Kunde/Request gecacht) - keine Laufzeit-Abhaengigkeit auf das
  BuchhaltungsWidget.
- Prognose ueberschritten + Rechnung noch offen -> "(ueberfaellig)"
  als zusaetzliches Mahnsignal.
- Mindest-Stichprobe MAHNUNG_PROGNOSE_MIN_N (Default 3), sonst
  "keine Historie". Neue Lang-Keys MahnungProg* (de_DE + en_US).
- $langs->transnoentities() statt trans() genutzt (sonst leere %s
  durch doppeltes sprintf bzw. doppeltes &-Encoding, siehe KB #897).

End-to-end via CLI-Harness gegen die lokale Test-DB verifiziert
(alle 5 Stufen, Overdue-Zweig, Empty-State, 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:30:09 +02:00
parent 7532770599
commit adfeafa09b
5 changed files with 183 additions and 4 deletions

View file

@ -2,6 +2,16 @@
## [Unreleased] ## [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`).
- **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 überschritten, Rechnung aber noch offen → Zusatz „(überfällig)" (Kunde zahlt schlechter als sonst = Mahnsignal).
- Mindest-Stichprobe fürs Datum via Konstante `MAHNUNG_PROGNOSE_MIN_N` (Default 3), 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.
### Schema-Fix (tms-Spalten) ### Schema-Fix (tms-Spalten)
- Die `tms`-Spalten von `llx_mahnung_mahnung`, `llx_mahnung_stufe` und `llx_mahnung_trackingpattern` wurden als reines `TIMESTAMP` angelegt — unter `explicit_defaults_for_timestamp` entstand daraus `NULL DEFAULT NULL`, sodass `tms` bei jedem UPDATE leer blieb. Jetzt Dolibarr-Standard `DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`, damit der Änderungszeitpunkt (z.B. einer per Zahlungstrigger erledigten Mahnung) wieder nachvollziehbar ist. - Die `tms`-Spalten von `llx_mahnung_mahnung`, `llx_mahnung_stufe` und `llx_mahnung_trackingpattern` wurden als reines `TIMESTAMP` angelegt — unter `explicit_defaults_for_timestamp` entstand daraus `NULL DEFAULT NULL`, sodass `tms` bei jedem UPDATE leer blieb. Jetzt Dolibarr-Standard `DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`, damit der Änderungszeitpunkt (z.B. einer per Zahlungstrigger erledigten Mahnung) wieder nachvollziehbar ist.
- Neue idempotente Migration `migrateTimestampSpalten()` (läuft im Modul-`init()`): befüllt bestehende `NULL`-Werte aus `datec` und stellt die Spalte per `ALTER TABLE` um. Greift auf Bestands-Installs beim Re-Aktivieren des Moduls. - Neue idempotente Migration `migrateTimestampSpalten()` (läuft im Modul-`init()`): befüllt bestehende `NULL`-Werte aus `datec` und stellt die Spalte per `ALTER TABLE` um. Greift auf Bestands-Installs beim Re-Aktivieren des Moduls.

View file

@ -31,6 +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) - **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. - **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`. - **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 + round(diff)`; Mindest-`n` via `MAHNUNG_PROGNOSE_MIN_N` (Default 3), darunter „keine Historie". 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 ## 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) - **`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

@ -27,6 +27,9 @@ class box_mahnung_offen extends ModeleBoxes
public $boxlabel = "MahnungBoxOffeneRechnungen"; public $boxlabel = "MahnungBoxOffeneRechnungen";
public $depends = array("facture", "mahnung"); public $depends = array("facture", "mahnung");
/** @var array<int,array|null> Zahlprognose je socid — 1 Query pro Kunde/Request (Cache) */
private static $prognoseCache = array();
/** /**
* @param DoliDB $db * @param DoliDB $db
* @param string $param * @param string $param
@ -129,7 +132,7 @@ class box_mahnung_offen extends ModeleBoxes
// leeres info_box_contents würde ModeleBoxes::showBox nichts rendern lassen. // leeres info_box_contents würde ModeleBoxes::showBox nichts rendern lassen.
if ($num == 0) { if ($num == 0) {
$this->info_box_contents[0][] = array( $this->info_box_contents[0][] = array(
'td' => 'class="center opacitymedium" colspan="6"', 'td' => 'class="center opacitymedium" colspan="7"',
'text' => $langs->trans("MahnungBoxKeineOffenenRechnungen"), 'text' => $langs->trans("MahnungBoxKeineOffenenRechnungen"),
); );
} }
@ -217,14 +220,21 @@ class box_mahnung_offen extends ModeleBoxes
'text' => dol_print_date($datelimit, 'day', 'tzuserrel'), 'text' => dol_print_date($datelimit, 'day', 'tzuserrel'),
); );
// Spalte 5: Mahnstufe // 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),
'asis' => 1,
);
// Spalte 6: Mahnstufe
$this->info_box_contents[$line][] = array( $this->info_box_contents[$line][] = array(
'td' => 'class="center nowraponall"', 'td' => 'class="center nowraponall"',
'text' => $mahnCell, 'text' => $mahnCell,
'asis' => 1, 'asis' => 1,
); );
// Spalte 6: Status (rechts am Rand, schmal) // Spalte 7: Status (rechts am Rand, schmal)
$this->info_box_contents[$line][] = array( $this->info_box_contents[$line][] = array(
'td' => 'class="nowraponall right" width="16"', 'td' => 'class="nowraponall right" width="16"',
'text' => $facturestatic->LibStatut($objp->paye, $objp->status, 3, $objp->am, $objp->type), 'text' => $facturestatic->LibStatut($objp->paye, $objp->status, 3, $objp->am, $objp->type),
@ -235,7 +245,7 @@ class box_mahnung_offen extends ModeleBoxes
// Mehr offene Rechnungen vorhanden als angezeigt: "..."-Zeile als Verweis // Mehr offene Rechnungen vorhanden als angezeigt: "..."-Zeile als Verweis
if ($boxmax > 0 && $num > $boxmax) { if ($boxmax > 0 && $num > $boxmax) {
$this->info_box_contents[$line][] = array('td' => 'colspan="6"', 'text' => '...'); $this->info_box_contents[$line][] = array('td' => 'colspan="7"', 'text' => '...');
$line++; $line++;
} }
@ -279,6 +289,12 @@ class box_mahnung_offen extends ModeleBoxes
.'<br><span class="opacitymedium paddingright small">'.$langs->trans("MahnungBoxBrutto").'</span>'.price($totalTtc, 0, $langs, 0, -1, -1, $conf->currency), .'<br><span class="opacitymedium paddingright small">'.$langs->trans("MahnungBoxBrutto").'</span>'.price($totalTtc, 0, $langs, 0, -1, -1, $conf->currency),
'asis' => 1, 'asis' => 1,
); );
// Fälligkeit-Spalte (leer)
$this->info_box_contents[$line][] = array(
'td' => 'class="liste_total"',
'text' => '&nbsp;',
);
// Vsl.-Zahlung-Spalte (leer)
$this->info_box_contents[$line][] = array( $this->info_box_contents[$line][] = array(
'td' => 'class="liste_total"', 'td' => 'class="liste_total"',
'text' => '&nbsp;', 'text' => '&nbsp;',
@ -296,6 +312,136 @@ class box_mahnung_offen extends ModeleBoxes
$this->db->free($result); $this->db->free($result);
} }
/**
* Zahlungsverhalten eines Kunden aus seinen bezahlten Rechnungen.
*
* Rechnung IDENTISCH zu BuchhaltungsWidget::getPaymentStatistics() (KB #886),
* damit die Ampel hier zur "Zahlungsverhalten"-Box auf der Kundenkarte passt:
* diff = Ø(Zahlung Rechnungsdatum) Ø(Fälligkeit Rechnungsdatum)
* = durchschnittliche Tage NACH Fälligkeit (negativ = vorher gezahlt).
* Bewusst nur Standard/Ersatz/Situation (type IN 0,1,5), voll bezahlt
* (fk_statut=2 + paye=1), mit Fälligkeit. Selbst-enthaltene Kopie
* KEINE Laufzeit-Abhängigkeit auf BuchhaltungsWidget.
*
* @param int $socid
* @return array{count:int,diff:float}|null null bei Fehler / ohne Historie
*/
private function getZahlprognose($socid)
{
$socid = (int) $socid;
if ($socid <= 0) {
return null;
}
if (array_key_exists($socid, self::$prognoseCache)) {
return self::$prognoseCache[$socid];
}
$sql = "SELECT COUNT(*) as invoice_count,";
$sql .= " AVG(DATEDIFF(sub.pay_date, f.datef)) as avg_payment_days,";
$sql .= " AVG(DATEDIFF(f.date_lim_reglement, f.datef)) as avg_due_days";
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
$sql .= " INNER JOIN (";
$sql .= " SELECT pf.fk_facture, MAX(p.datep) as pay_date";
$sql .= " FROM ".MAIN_DB_PREFIX."paiement_facture as pf";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."paiement as p ON p.rowid = pf.fk_paiement";
$sql .= " GROUP BY pf.fk_facture";
$sql .= " ) as sub ON sub.fk_facture = f.rowid";
$sql .= " WHERE f.fk_soc = ".$socid;
$sql .= " AND f.fk_statut = 2 AND f.paye = 1";
$sql .= " AND f.type IN (0, 1, 5)";
$sql .= " AND f.date_lim_reglement IS NOT NULL";
$sql .= " AND f.entity IN (".getEntity('invoice').")";
$out = null;
$res = $this->db->query($sql);
if ($res) {
$o = $this->db->fetch_object($res);
if ($o && (int) $o->invoice_count > 0) {
$out = array(
'count' => (int) $o->invoice_count,
'diff' => (float) $o->avg_payment_days - (float) $o->avg_due_days,
);
}
$this->db->free($res);
} else {
dol_syslog("box_mahnung_offen::getZahlprognose SQL-Fehler: ".$this->db->lasterror(), LOG_ERR);
}
self::$prognoseCache[$socid] = $out;
return $out;
}
/**
* Bewertungsstufe zu einem diff-Wert (Ø Tage nach Fälligkeit).
* Schwellen, Farben und Icons IDENTISCH zu BuchhaltungsWidget (KB #886).
*
* @param float $diff
* @return array{icon:string,color:string,labelkey:string}
*/
private function prognoseRating($diff)
{
if ($diff <= -5) {
return array('icon' => '&#9733;', 'color' => '#28a745', 'labelkey' => 'MahnungProgVorbildlich'); // ★
} elseif ($diff <= 0) {
return array('icon' => '&#10003;', 'color' => '#28a745', 'labelkey' => 'MahnungProgPuenktlich'); // ✓
} elseif ($diff <= 7) {
return array('icon' => '&#9888;', 'color' => '#ffc107', 'labelkey' => 'MahnungProgSpaetzahler'); // ⚠
} elseif ($diff <= 14) {
return array('icon' => '&#9201;', 'color' => '#fd7e14', 'labelkey' => 'MahnungProgVerspaetet'); // ⏱
}
return array('icon' => '&#10007;', 'color' => '#dc3545', 'labelkey' => 'MahnungProgProblematisch'); // ✗
}
/**
* 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).
*
* @param int $socid
* @param int $datelimit Fälligkeit als Unix-Timestamp (jdate)
* @return string HTML (wird 'asis' gerendert)
*/
private function buildPrognoseCell($socid, $datelimit)
{
global $langs;
$prog = $this->getZahlprognose($socid);
$minN = getDolGlobalInt('MAHNUNG_PROGNOSE_MIN_N', 3);
// transnoentities() liefert rohes UTF-8 (kein &uuml;) — sonst würde das
// nachfolgende dol_escape_htmltag() das & doppelt kodieren.
// Zu wenig Historie (oder gar keine) → keine belastbare Prognose
if ($prog === null || $prog['count'] < $minN) {
return '<span class="opacitymedium" title="'.dol_escape_htmltag($langs->transnoentities('MahnungProgKeineHistorieTip')).'">'
.'&#9675; '.dol_escape_htmltag($langs->transnoentities('MahnungProgKeineHistorie')).'</span>';
}
$rating = $this->prognoseRating($prog['diff']);
$label = $langs->transnoentities($rating['labelkey']);
$tage = (int) round($prog['diff']);
$progTs = (int) $datelimit + ($tage * 86400);
$progDate = dol_print_date($progTs, 'day', 'tzuserrel');
$avg = sprintf('%+d', $tage); // Vorzeichen: + nach Fälligkeit, davor
// Prognosedatum überschritten, Rechnung noch offen → schlechter als sonst
$ueberf = '';
if ($progTs < dol_now()) {
$ueberf = ' <span style="color:#dc3545;">('.dol_escape_htmltag($langs->transnoentities('MahnungProgUeberfaellig')).')</span>';
}
// Parameter direkt an trans übergeben (macht das %s-sprintf selbst).
$tooltip = $langs->transnoentities('MahnungProgTooltip', $progDate, $avg, (string) $prog['count']);
$html = '<span title="'.dol_escape_htmltag($tooltip).'">';
$html .= '<span style="color:'.$rating['color'].';">'.$rating['icon'].'</span> ~'.$progDate.$ueberf;
$html .= '<br><span class="opacitymedium small">'.dol_escape_htmltag($label)
.' &middot; &#216; '.$avg.'&nbsp;T &middot; n='.((int) $prog['count']).'</span>';
$html .= '</span>';
return $html;
}
/** /**
* @param array|null $head * @param array|null $head
* @param array|null $contents * @param array|null $contents

View file

@ -211,6 +211,17 @@ MahnungBoxMaxLinesAll = Alle
MahnungBoxMaxLinesHelp = Wie viele offene Rechnungen das Home-Widget maximal anzeigt. „Alle" = ohne Begrenzung. Der Zähler im Widget-Kopf zeigt immer die tatsächliche Gesamtzahl. MahnungBoxMaxLinesHelp = Wie viele offene Rechnungen das Home-Widget maximal anzeigt. „Alle" = ohne Begrenzung. Der Zähler im Widget-Kopf zeigt immer die tatsächliche Gesamtzahl.
MahnungBoxNetto = Netto MahnungBoxNetto = Netto
MahnungBoxBrutto = Brutto MahnungBoxBrutto = Brutto
# Widget: Zahlungsprognose (Skala identisch BuchhaltungsWidget / KB #886)
MahnungProgVslZahlung = Vsl. Zahlung
MahnungProgVorbildlich = Vorbildlich
MahnungProgPuenktlich = Pünktlich
MahnungProgSpaetzahler = Spätzahler
MahnungProgVerspaetet = Verspätet
MahnungProgProblematisch = Problematisch
MahnungProgKeineHistorie = keine Historie
MahnungProgKeineHistorieTip = Zu wenig bezahlte Rechnungen für eine belastbare Zahlungsprognose
MahnungProgUeberfaellig = überfällig
MahnungProgTooltip = Voraussichtlich am %s (im Schnitt %s Tage zur Fälligkeit, %s bezahlte Rechnungen)
# #
# Dokumentenmodelle # Dokumentenmodelle

View file

@ -336,6 +336,17 @@ MahnungBoxMaxLinesAll = All
MahnungBoxMaxLinesHelp = How many open invoices the home widget shows at most. "All" = no limit. The counter in the widget header always shows the real total. MahnungBoxMaxLinesHelp = How many open invoices the home widget shows at most. "All" = no limit. The counter in the widget header always shows the real total.
MahnungBoxNetto = Net MahnungBoxNetto = Net
MahnungBoxBrutto = Gross MahnungBoxBrutto = Gross
# Widget: payment forecast (scale identical to BuchhaltungsWidget / KB #886)
MahnungProgVslZahlung = Est. payment
MahnungProgVorbildlich = Exemplary
MahnungProgPuenktlich = On time
MahnungProgSpaetzahler = Slightly late
MahnungProgVerspaetet = Late
MahnungProgProblematisch = Problematic
MahnungProgKeineHistorie = no history
MahnungProgKeineHistorieTip = Too few paid invoices for a reliable payment forecast
MahnungProgUeberfaellig = overdue
MahnungProgTooltip = Expected on %s (on average %s days relative to due date, %s paid invoices)
MahnungVorschlagStufeNichtKonfiguriert = Stage 1 not configured MahnungVorschlagStufeNichtKonfiguriert = Stage 1 not configured
MahnungVorschlagFristNichtErreicht = Stage 1 deadline (%s days) not yet reached (overdue %s days) MahnungVorschlagFristNichtErreicht = Stage 1 deadline (%s days) not yet reached (overdue %s days)
MahnungVorschlagAlleStufenAusgeschoepft = All dunning stages exhausted (last stage %s) MahnungVorschlagAlleStufenAusgeschoepft = All dunning stages exhausted (last stage %s)