diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1f499..82ffc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [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 = Ø(Zahlung−Rechnungsdatum) − Ø(Fälligkeit−Rechnungsdatum)` = Ø 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) - 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. diff --git a/CLAUDE.md b/CLAUDE.md index 287735d..aa1edbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) - **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 = Ø(Zahlung−Rechnungsdatum) − Ø(Fälligkeit−Rechnungsdatum)`; 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 - **`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) diff --git a/core/boxes/box_mahnung_offen.php b/core/boxes/box_mahnung_offen.php index 9ff7c55..a02422e 100644 --- a/core/boxes/box_mahnung_offen.php +++ b/core/boxes/box_mahnung_offen.php @@ -27,6 +27,9 @@ class box_mahnung_offen extends ModeleBoxes public $boxlabel = "MahnungBoxOffeneRechnungen"; public $depends = array("facture", "mahnung"); + /** @var array Zahlprognose je socid — 1 Query pro Kunde/Request (Cache) */ + private static $prognoseCache = array(); + /** * @param DoliDB $db * @param string $param @@ -129,7 +132,7 @@ class box_mahnung_offen extends ModeleBoxes // leeres info_box_contents würde ModeleBoxes::showBox nichts rendern lassen. if ($num == 0) { $this->info_box_contents[0][] = array( - 'td' => 'class="center opacitymedium" colspan="6"', + 'td' => 'class="center opacitymedium" colspan="7"', 'text' => $langs->trans("MahnungBoxKeineOffenenRechnungen"), ); } @@ -217,14 +220,21 @@ class box_mahnung_offen extends ModeleBoxes '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( 'td' => 'class="center nowraponall"', 'text' => $mahnCell, 'asis' => 1, ); - // Spalte 6: Status (rechts am Rand, schmal) + // Spalte 7: Status (rechts am Rand, schmal) $this->info_box_contents[$line][] = array( 'td' => 'class="nowraponall right" width="16"', '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 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++; } @@ -279,6 +289,12 @@ class box_mahnung_offen extends ModeleBoxes .'
'.$langs->trans("MahnungBoxBrutto").''.price($totalTtc, 0, $langs, 0, -1, -1, $conf->currency), 'asis' => 1, ); + // Fälligkeit-Spalte (leer) + $this->info_box_contents[$line][] = array( + 'td' => 'class="liste_total"', + 'text' => ' ', + ); + // Vsl.-Zahlung-Spalte (leer) $this->info_box_contents[$line][] = array( 'td' => 'class="liste_total"', 'text' => ' ', @@ -296,6 +312,136 @@ class box_mahnung_offen extends ModeleBoxes $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' => '★', 'color' => '#28a745', 'labelkey' => 'MahnungProgVorbildlich'); // ★ + } elseif ($diff <= 0) { + return array('icon' => '✓', 'color' => '#28a745', 'labelkey' => 'MahnungProgPuenktlich'); // ✓ + } elseif ($diff <= 7) { + return array('icon' => '⚠', 'color' => '#ffc107', 'labelkey' => 'MahnungProgSpaetzahler'); // ⚠ + } elseif ($diff <= 14) { + return array('icon' => '⏱', 'color' => '#fd7e14', 'labelkey' => 'MahnungProgVerspaetet'); // ⏱ + } + return array('icon' => '✗', '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 ü) — 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 '' + .'○ '.dol_escape_htmltag($langs->transnoentities('MahnungProgKeineHistorie')).''; + } + + $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 = ' ('.dol_escape_htmltag($langs->transnoentities('MahnungProgUeberfaellig')).')'; + } + + // Parameter direkt an trans übergeben (macht das %s-sprintf selbst). + $tooltip = $langs->transnoentities('MahnungProgTooltip', $progDate, $avg, (string) $prog['count']); + + $html = ''; + $html .= ''.$rating['icon'].' ~'.$progDate.$ueberf; + $html .= '
'.dol_escape_htmltag($label) + .' · Ø '.$avg.' T · n='.((int) $prog['count']).''; + $html .= '
'; + + return $html; + } + /** * @param array|null $head * @param array|null $contents diff --git a/langs/de_DE/mahnung.lang b/langs/de_DE/mahnung.lang index aa0c928..e96bb44 100644 --- a/langs/de_DE/mahnung.lang +++ b/langs/de_DE/mahnung.lang @@ -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. MahnungBoxNetto = Netto 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 diff --git a/langs/en_US/mahnung.lang b/langs/en_US/mahnung.lang index 0ed1265..7b28a4b 100644 --- a/langs/en_US/mahnung.lang +++ b/langs/en_US/mahnung.lang @@ -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. MahnungBoxNetto = Net 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 MahnungVorschlagFristNichtErreicht = Stage 1 deadline (%s days) not yet reached (overdue %s days) MahnungVorschlagAlleStufenAusgeschoepft = All dunning stages exhausted (last stage %s)