mahnung/core/boxes/box_mahnung_offen.php
Eduard Wisch 4997a78599
All checks were successful
Deploy mahnung / deploy (push) Successful in 13s
Widget-Prognose: Paritaet zur Kundenkarten-Box + Frueh-Signal statt "ueberfaellig" [deploy]
Fix nach Eddys Beobachtung: Kunde mit Historie auf der Kundenkarte
(BuchhaltungsWidget bewertet ab n>=1) erschien im Widget als "keine
Historie", weil MAHNUNG_PROGNOSE_MIN_N auf 3 stand.

- MIN_N Default 3 -> 1: Paritaet zur Zahlungsverhalten-Box, die ebenfalls
  ab der 1. bezahlten Rechnung bewertet. Weiter per Konstante erhoehbar.
- "(ueberfaellig)" -> "(spaeter als ueblich)": Das Flag greift wenn das
  Prognosedatum verstrichen und die Rechnung noch offen ist - das kann
  schon VOR der formellen Faelligkeit passieren (Fruehzahler). "ueberfaellig"
  war dafuer das falsche Wort. Neuer Lang-Key MahnungProgSpaeterAlsUeblich.
- Prognosedatum + OE-Verzug jetzt aus dem EXAKTEN Mittelwert (Timestamp
  statt gerundeter Tage), Anzeige mit einer Nachkommastelle -> deckt sich
  1:1 mit "Differenz" auf der Kundenkarte (z.B. -3.1 T -> Faelligkeit-3.1).

Lokal gegen die Test-DB verifiziert (n=1/n=2 werden gezeigt, Flag nur bei
verstrichener Prognose, Zukunfts-Faelligkeit ohne Flag, php -l sauber).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:39:25 +02:00

461 lines
18 KiB
PHP
Raw 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';
/**
* 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();
/**
* @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
$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 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 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 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;
while ($line < $renderLimit) {
$objp = $this->db->fetch_object($result);
$datelimit = $this->db->jdate($objp->datelimit);
$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
$mahnCell = '<span class="opacitymedium">—</span>';
if (!empty($objp->mahnstufe)) {
$stufe = (int) $objp->mahnstufe;
$colors = array(1 => '#4a90d9', 2 => '#e68a00', 3 => '#cc3333');
$color = $colors[$stufe] ?? '#666';
$label = $langs->trans('MahnungBoxStufe', $stufe);
$mahnDatum = $objp->mahndatum ? dol_print_date($this->db->jdate($objp->mahndatum), 'day') : '';
$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).'">'.$label.'</span>';
if (!empty($objp->mahnid)) {
$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),
'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);
}
/**
* 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);
// 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 aus dem EXAKTEN Ø-Verzug (gleiche Zahl wie "Differenz" auf der
// Kundenkarte); Anzeige mit einer Nachkommastelle, damit z.B. 3.1 exakt passt.
$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
// 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 = '';
if ($progTs < dol_now()) {
$hint = ' <span style="color:#dc3545;">('.dol_escape_htmltag($langs->transnoentities('MahnungProgSpaeterAlsUeblich')).')</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.$hint;
$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 $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);
}
}