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>
552 lines
22 KiB
PHP
552 lines
22 KiB
PHP
<?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' => ' ',
|
||
);
|
||
// 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' => ' ',
|
||
);
|
||
// Vsl.-Zahlung-Spalte (leer)
|
||
$this->info_box_contents[$line][] = array(
|
||
'td' => 'class="liste_total"',
|
||
'text' => ' ',
|
||
);
|
||
$this->info_box_contents[$line][] = array(
|
||
'td' => 'class="liste_total"',
|
||
'text' => ' ',
|
||
);
|
||
$this->info_box_contents[$line][] = array(
|
||
'td' => 'class="liste_total"',
|
||
'text' => ' ',
|
||
);
|
||
}
|
||
|
||
$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' => '★', '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.
|
||
*
|
||
* 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 ü) — 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')).'">'
|
||
.'○ '.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">Ø '.$nachRe.' '
|
||
.dol_escape_htmltag($langs->transnoentities('MahnungProgNachRechnung')).' · 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);
|
||
}
|
||
}
|