mahnung/core/boxes/box_mahnung_offen.php
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

552 lines
22 KiB
PHP
Raw Permalink 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.

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License, version 3.
*/
/**
* \file htdocs/custom/mahnung/core/boxes/box_mahnung_offen.php
* \ingroup mahnung
* \brief Widget: Aelteste offene Kundenrechnungen mit Mahnstufe.
* Basiert auf box_factures_imp.php, erweitert um Mahnstufe-Spalte.
*/
require_once DOL_DOCUMENT_ROOT.'/core/boxes/modules_boxes.php';
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_ui.lib.php';
/**
* Widget: Aelteste offene Kundenrechnungen mit Mahnstufe-Badge.
*/
class box_mahnung_offen extends ModeleBoxes
{
public $boxcode = "mahnungoffenerechnungen";
public $boximg = "object_bill";
public $boxlabel = "MahnungBoxOffeneRechnungen";
public $depends = array("facture", "mahnung");
/** @var array<int,array|null> Zahlprognose je socid — 1 Query pro Kunde/Request (Cache) */
private static $prognoseCache = array();
/** @var int[]|null Stufennummern mit ist_erinnerung = 1 — 1 Query pro Request (Cache) */
private static $erinnerungsStufenCache = null;
/**
* @param DoliDB $db
* @param string $param
*/
public function __construct($db, $param)
{
global $user;
$this->db = $db;
$this->hidden = !($user->hasRight('facture', 'lire'));
}
/**
* @param int $max
*/
public function loadBox($max = 5)
{
global $conf, $user, $langs;
$this->max = $max;
$langs->loadLangs(array('bills', 'mahnung@mahnung'));
$facturestatic = new Facture($this->db);
$societestatic = new Societe($this->db);
// Kopf mit Titel; die echte Anzahl offener Rechnungen wird nach der Query
// als Badge ergaenzt (siehe unten). Fuer die fruehen Return-Pfade
// (fehlende Rechte / SQL-Fehler) bleibt es beim reinen Titel.
$textHead = $langs->trans("MahnungBoxOffeneRechnungen");
$this->info_box_head = array(
'text' => $textHead,
'limit' => dol_strlen($textHead),
);
if (!$user->hasRight('facture', 'lire')) {
$this->info_box_contents[0][0] = array(
'td' => 'class="nohover left"',
'text' => '<span class="opacitymedium">'.$langs->trans("ReadPermissionNotAllowed").'</span>'
);
return;
}
// Query wie box_factures_imp, plus Mahnstufe per Subquery
$sql = "SELECT s.rowid as socid, s.nom as name, s.code_client, s.client,";
$sql .= " s.logo, s.email, s.entity,";
$sql .= " f.rowid as facid, f.ref, f.type, f.datef as date,";
$sql .= " f.date_lim_reglement as datelimit,";
$sql .= " f.total_ht, f.total_tva, f.total_ttc,";
$sql .= " f.paye, f.fk_statut as status,";
$sql .= " SUM(pf.amount) as am,";
// Letzte aktive Mahnstufe. Der Zweit-Sortierschlüssel rowid DESC ist Pflicht:
// ohne ihn könnten die drei Subqueries bei zwei Mahnvorgängen derselben Stufe
// unterschiedliche Zeilen treffen (Badge-Datum und Kartenlink aus verschiedenen
// Mahnungen). Stufe 0 (Zahlungserinnerung) ist eine gültige Stufe.
$sql .= " (SELECT m2.stufe FROM ".MAIN_DB_PREFIX."mahnung_mahnung as m2";
$sql .= " WHERE m2.fk_facture = f.rowid AND m2.status != ".((int) Mahnung::STATUS_STORNIERT);
$sql .= " ORDER BY m2.stufe DESC, m2.rowid DESC LIMIT 1) as mahnstufe,";
$sql .= " (SELECT m3.date_mahnung FROM ".MAIN_DB_PREFIX."mahnung_mahnung as m3";
$sql .= " WHERE m3.fk_facture = f.rowid AND m3.status != ".((int) Mahnung::STATUS_STORNIERT);
$sql .= " ORDER BY m3.stufe DESC, m3.rowid DESC LIMIT 1) as mahndatum,";
$sql .= " (SELECT m4.rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung as m4";
$sql .= " WHERE m4.fk_facture = f.rowid AND m4.status != ".((int) Mahnung::STATUS_STORNIERT);
$sql .= " ORDER BY m4.stufe DESC, m4.rowid DESC LIMIT 1) as mahnid";
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."paiement_facture as pf ON f.rowid = pf.fk_facture";
$sql .= " WHERE f.entity IN (".getEntity('invoice').")";
$sql .= " AND f.paye = 0";
$sql .= " AND f.fk_statut = 1";
if (empty($user->socid) && !$user->hasRight('societe', 'client', 'voir')) {
$sql .= " AND s.rowid = (SELECT sc.fk_soc FROM ".MAIN_DB_PREFIX."societe_commerciaux as sc WHERE sc.fk_soc = s.rowid AND sc.fk_user = ".((int) $user->id)." LIMIT 1)";
}
if ($user->socid) {
$sql .= " AND s.rowid = ".((int) $user->socid);
}
$sql .= " GROUP BY s.rowid, s.nom, s.code_client, s.client, s.logo, s.email, s.entity,";
$sql .= " f.rowid, f.ref, f.type, f.datef, f.date_lim_reglement,";
$sql .= " f.total_ht, f.total_tva, f.total_ttc, f.paye, f.fk_statut";
$sql .= " ORDER BY f.date_lim_reglement ASC, f.ref ASC";
// Kein plimit: alle offenen Rechnungen laden, damit der Zaehler im Kopf
// stimmt. Die Anzeige wird bei Bedarf ueber MAHNUNG_BOX_MAXLINES begrenzt.
$result = $this->db->query($sql);
if (!$result) {
$this->info_box_contents[0][0] = array(
'td' => '', 'maxlength' => 500,
'text' => $this->db->error().' sql='.$sql,
);
return;
}
$num = $this->db->num_rows($result);
$line = 0;
$l_due_date = $langs->trans('Late').' ('.strtolower($langs->trans('DateDue')).': %s)';
// Echte Anzahl offener Rechnungen als Badge im Kopf (verlinkt auf die Liste).
// $num entspricht dank GROUP BY genau einer Zeile je offener Rechnung.
$this->info_box_head['text'] = $textHead
.'<a class="paddingleft valignmiddle" href="'.DOL_URL_ROOT.'/compta/facture/list.php?search_status=1&sortfield=f.date_lim_reglement,f.ref&sortorder=ASC,ASC"'
.' title="'.dol_escape_htmltag($textHead).'"><span class="badge">'.((int) $num).'</span></a>';
// Damit das Widget auch ohne offene Rechnungen sichtbar bleibt:
// 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="7"',
'text' => $langs->trans("MahnungBoxKeineOffenenRechnungen"),
);
}
// Anzeige-Limit aus dem Setup (0 = alle offenen Rechnungen anzeigen).
// Es werden immer alle geladen (fuer den korrekten Zaehler im Kopf),
// aber nur $renderLimit Zeilen gerendert.
$boxmax = getDolGlobalInt('MAHNUNG_BOX_MAXLINES', 0);
$renderLimit = ($boxmax > 0) ? min($num, $boxmax) : $num;
// Stufen-Konfiguration einmal laden — daraus kommt die Kennzeichnung
// "kostenlose Zahlungserinnerung" (Flag ist_erinnerung) für das Badge.
$erinnerungsStufen = $this->getErinnerungsStufen();
while ($line < $renderLimit) {
$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;
$facturestatic->type = $objp->type;
$facturestatic->total_ht = $objp->total_ht;
$facturestatic->total_tva = $objp->total_tva;
$facturestatic->total_ttc = $objp->total_ttc;
$facturestatic->date = $this->db->jdate($objp->date);
$facturestatic->date_lim_reglement = $datelimit;
$facturestatic->statut = $objp->status;
$facturestatic->status = $objp->status;
$facturestatic->paye = $objp->paye;
$facturestatic->paid = $objp->paye;
$facturestatic->alreadypaid = $objp->am;
$facturestatic->totalpaid = $objp->am;
$societestatic->id = $objp->socid;
$societestatic->name = $objp->name;
$societestatic->code_client = $objp->code_client;
$societestatic->client = $objp->client;
$societestatic->logo = $objp->logo;
$societestatic->email = $objp->email;
$societestatic->entity = $objp->entity;
$late = '';
if ($facturestatic->hasDelay()) {
$late = img_warning(sprintf($l_due_date, dol_print_date($datelimit, 'day', 'tzuserrel')));
}
// Mahnstufe-Badge (mit Link zur Mahnung) oder Strich für keine Mahnung.
// KEIN empty(): die Zahlungserinnerung hat per Default die Stufennummer 0,
// empty('0') wäre true — die Zeile würde fälschlich als "keine Mahnung"
// (Strich, kein Link zur Karte) erscheinen. Nur NULL/'' bedeutet
// "zu dieser Rechnung existiert kein Mahnvorgang".
$mahnCell = '<span class="opacitymedium">—</span>';
if ($objp->mahnstufe !== null && $objp->mahnstufe !== '') {
$stufe = (int) $objp->mahnstufe;
// Ob es eine kostenlose Zahlungserinnerung ist, entscheidet ausschließlich
// das Flag ist_erinnerung der Stufe — NICHT die Stufennummer.
$istErinnerung = in_array($stufe, $erinnerungsStufen, true);
$mahnDatum = $objp->mahndatum ? dol_print_date($this->db->jdate($objp->mahndatum), 'day') : '';
// Farbskala kommt aus lib/mahnung_ui.lib.php — dieselbe Quelle wie auf
// der Mahnungskarte und in der Vorschlagsliste.
$color = mahnungStufeFarbe($stufe, $istErinnerung);
if ($istErinnerung) {
$label = $langs->trans('MahnungBoxErinnerung');
$tooltip = $mahnDatum
? $langs->trans('MahnungBoxErinnerungVom', $mahnDatum)
: $langs->trans('MahnungIstErinnerung');
} else {
// Im Widget bewusst nur "Stufe N" statt der ausgeschriebenen
// Bezeichnung: die Spalte ist schmal, der Klick führt zur Karte.
$label = $langs->trans('MahnungBoxStufe', $stufe);
$tooltip = $mahnDatum ? $langs->trans('MahnungBoxStufeVom', $stufe, $mahnDatum) : $label;
}
$badge = '<span class="badge" style="background-color:'.$color.';color:#fff;font-size:0.75em;" title="'.dol_escape_htmltag($tooltip).'">'.dol_escape_htmltag($label).'</span>';
if ((int) $objp->mahnid > 0) {
$mahnCell = '<a href="'.DOL_URL_ROOT.'/custom/mahnung/card.php?id='.((int) $objp->mahnid).'">'.$badge.'</a>';
} else {
$mahnCell = $badge;
}
}
// Spalte 1: Rechnung + Warnung
$this->info_box_contents[$line][] = array(
'td' => 'class="nowraponall"',
'text' => $facturestatic->getNomUrl(1),
'text2' => $late,
'asis' => 1,
);
// Spalte 2: Kunde
$this->info_box_contents[$line][] = array(
'td' => 'class="tdoverflowmax150 maxwidth150onsmartphone"',
'text' => $societestatic->getNomUrl(1, '', 44),
'asis' => 1,
);
// Spalte 3: Betrag
$this->info_box_contents[$line][] = array(
'td' => 'class="nowraponall right amount"',
'text' => price($objp->total_ht, 0, $langs, 0, -1, -1, $conf->currency),
);
// Spalte 4: Fälligkeitsdatum
$this->info_box_contents[$line][] = array(
'td' => 'class="center nowraponall" title="'.dol_escape_htmltag($langs->trans("DateDue").': '.dol_print_date($datelimit, 'day', 'tzuserrel')).'"',
'text' => dol_print_date($datelimit, 'day', 'tzuserrel'),
);
// 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, $dateinvoice),
'asis' => 1,
);
// Spalte 6: Mahnstufe
$this->info_box_contents[$line][] = array(
'td' => 'class="center nowraponall"',
'text' => $mahnCell,
'asis' => 1,
);
// 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),
);
$line++;
}
// Mehr offene Rechnungen vorhanden als angezeigt: "..."-Zeile als Verweis
if ($boxmax > 0 && $num > $boxmax) {
$this->info_box_contents[$line][] = array('td' => 'colspan="7"', 'text' => '...');
$line++;
}
if ($num > 0) {
// Summe (wie Original: separate Query ohne LIMIT).
// Brutto direkt aus f.total_ttc der Rechnung, NICHT aus total_ht
// hochgerechnet — so bleiben Sonderregelungen (Reverse-Charge §13b,
// Steuerbefreiung, Kleinunternehmer §19 UStG) korrekt.
$sql2 = "SELECT SUM(f.total_ht) as total_ht, SUM(f.total_ttc) as total_ttc";
$sql2 .= " FROM ".MAIN_DB_PREFIX."facture as f";
$sql2 .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc";
$sql2 .= " WHERE f.entity IN (".getEntity('invoice').")";
$sql2 .= " AND f.paye = 0 AND f.fk_statut = 1";
if ($user->socid) {
$sql2 .= " AND s.rowid = ".((int) $user->socid);
}
$resTotal = $this->db->query($sql2);
$totalHt = 0;
$totalTtc = 0;
if ($resTotal) {
$objTotal = $this->db->fetch_object($resTotal);
$totalHt = (float) $objTotal->total_ht;
$totalTtc = (float) $objTotal->total_ttc;
$this->db->free($resTotal);
}
$this->info_box_contents[$line][] = array(
'tr' => 'class="liste_total"',
'td' => 'class="liste_total"',
'text' => $langs->trans("Total"),
);
$this->info_box_contents[$line][] = array(
'td' => 'class="liste_total"',
'text' => '&nbsp;',
);
// Netto (wie bisher) + Brutto darunter, Brutto aus f.total_ttc der
// Rechnung (Sonderregelungen wie §13b/§19 UStG bleiben korrekt).
$this->info_box_contents[$line][] = array(
'td' => 'class="nowraponall right liste_total"',
'text' => '<span class="opacitymedium paddingright small">'.$langs->trans("MahnungBoxNetto").'</span>'.price($totalHt, 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,
);
// 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(
'td' => 'class="liste_total"',
'text' => '&nbsp;',
);
$this->info_box_contents[$line][] = array(
'td' => 'class="liste_total"',
'text' => '&nbsp;',
);
$this->info_box_contents[$line][] = array(
'td' => 'class="liste_total"',
'text' => '&nbsp;',
);
}
$this->db->free($result);
}
/**
* Stufennummern der Entity, die als kostenlose Zahlungserinnerung konfiguriert
* sind (Flag ist_erinnerung = 1). Maßgeblich ist das Flag, NICHT die Nummer —
* Stufennummern sind frei wählbar. Wird pro Request genau einmal gelesen
* (kleine Tabelle, wenige Zeilen).
*
* Bewusst "SELECT *": auf Installationen, auf denen die Schema-Migration
* (Setup-Seite einmal aufrufen) noch nicht lief, fehlt die Spalte
* ist_erinnerung — ein explizites Feld-SELECT würde die Query und damit das
* ganze Widget in einen SQL-Fehler laufen lassen. Dort bleibt die Liste leer
* und das Widget zeigt wie bisher nur die Stufennummer.
* Auch INAKTIVE Stufen werden geladen, damit Bestandsmahnungen auf einer
* später deaktivierten Stufe weiterhin richtig beschriftet werden.
*
* @return int[] Stufennummern mit ist_erinnerung = 1
*/
private function getErinnerungsStufen()
{
if (self::$erinnerungsStufenCache !== null) {
return self::$erinnerungsStufenCache;
}
self::$erinnerungsStufenCache = array();
$sql = "SELECT * FROM ".MAIN_DB_PREFIX."mahnung_stufe";
$sql .= " WHERE entity IN (".getEntity('mahnung').")";
$resql = $this->db->query($sql);
if (!$resql) {
// Kein harter Fehler: das Widget fällt dann auf die reine Stufennummer zurück
dol_syslog("box_mahnung_offen::getErinnerungsStufen SQL-Fehler: ".$this->db->lasterror(), LOG_ERR);
return self::$erinnerungsStufenCache;
}
while ($obj = $this->db->fetch_object($resql)) {
// isset-Guard: Spalte fehlt, solange die Schema-Migration noch nicht lief
if (!empty($obj->ist_erinnerung)) {
self::$erinnerungsStufenCache[] = (int) $obj->stufe;
}
}
$this->db->free($resql);
return self::$erinnerungsStufenCache;
}
/**
* 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,
// 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,
);
}
$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.
*
* 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) — Fallback-Anker
* @param int $dateinvoice Rechnungsdatum (datef) als Unix-Timestamp (jdate)
* @return string HTML (wird 'asis' gerendert)
*/
private function buildPrognoseCell($socid, $datelimit, $dateinvoice)
{
global $langs;
$prog = $this->getZahlprognose($socid);
// Default 1 = Parität zur "Zahlungsverhalten"-Box (BuchhaltungsWidget, KB #886),
// die ab der 1. bezahlten Rechnung bewertet. Höher setzen, wenn Einzelrechnungs-
// Prognosen als zu unsicher empfunden werden.
$minN = getDolGlobalInt('MAHNUNG_PROGNOSE_MIN_N', 1);
// 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']);
// 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');
$nachRe = sprintf('%.1f', $prog['avg_pay']); // Ø Tage nach Rechnungseingang (i.d.R. positiv)
// 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()) {
$dateStyle = ' style="color:#dc3545;"';
$tipExtra = ' — '.$langs->transnoentities('MahnungProgSpaeterAlsUeblich');
}
// 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> ';
$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;
}
/**
* @param array|null $head
* @param array|null $contents
* @param int $nooutput
* @return string
*/
public function showBox($head = null, $contents = null, $nooutput = 0)
{
return parent::showBox($this->info_box_head, $this->info_box_contents, $nooutput);
}
}