*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file mahnung/list.php
* \ingroup mahnung
* \brief Vorschlagsliste (mode=vorschlag) und Mahnvorgänge-Archiv (mode=archiv).
*/
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
// Nur wegen mahnungVorschlagFehlertext() — damit der Fehlertext des Vorschlag-Service
// überall identisch aufbereitet wird (eine Quelle statt kopierter Fallback-Logik).
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_anlage.lib.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_ui.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/modMahnung.class.php';
global $langs, $user, $db;
// Schema nach einem reinen Datei-Deploy nachziehen (Pipeline ruft weder init()
// noch zwangsläufig die Setup-Seite auf). Im Regelfall nur ein Konstanten-
// Vergleich ohne Query — siehe modMahnung::ensureSchema().
modMahnung::ensureSchema($db);
$langs->loadLangs(array('mahnung@mahnung', 'companies', 'bills'));
if (!$user->hasRight('mahnung', 'read')) {
accessforbidden();
}
$form = new Form($db);
$mode = GETPOST('mode', 'aZ09');
if ($mode !== 'archiv') {
$mode = 'vorschlag';
}
$filter = array();
// Stufen-Filter: 0 ist eine gültige Stufennummer (kostenlose Zahlungserinnerung),
// "nicht gesetzt" darf deshalb NIE über empty()/falsy entschieden werden.
// Auch GETPOST(..., 'int') taugt hier nicht als alleinige Weiche: je nach
// Dolibarr-Version liefert es für einen leeren Parameter '' oder 0 — im zweiten
// Fall würde die Option "— Alle —" stumm zu "nur Stufe 0" werden und Eddy sähe
// keine einzige echte Mahnung mehr. Deshalb den Rohwert prüfen: nur eine echte
// Ganzzahl im Request setzt den Filter. $filter_stufe ist danach '' oder int.
$filter_stufe = '';
$filterStufeRaw = trim((string) GETPOST('filter_stufe', 'alphanohtml'));
if ($filterStufeRaw !== '' && preg_match('/^-?\d+$/', $filterStufeRaw)) {
$filter_stufe = (int) $filterStufeRaw;
$filter['stufe'] = $filter_stufe;
}
$filter_minverzug = GETPOST('filter_minverzug', 'int');
if ($filter_minverzug !== '' && $filter_minverzug !== null) {
$filter['min_tage_verzug'] = (int) $filter_minverzug;
}
$filter_socid = GETPOST('search_socid', 'int');
// select_company liefert -1 für "nichts ausgewählt" — nur positive IDs als Filter werten
if ((int) $filter_socid > 0) {
$filter['soc_id'] = (int) $filter_socid;
}
$filter_minbetrag = GETPOST('filter_minbetrag', 'alpha'); // Komma/Punkt zugelassen
$filter_minbetrag_num = ($filter_minbetrag !== '' && $filter_minbetrag !== null)
? (float) str_replace(',', '.', $filter_minbetrag)
: null;
if ($filter_minbetrag_num !== null) {
$filter['min_betrag'] = $filter_minbetrag_num;
}
$filter_kundentyp = GETPOST('filter_kundentyp', 'alpha'); // '', 'B2B', 'B2C'
if ($filter_kundentyp === 'B2B' || $filter_kundentyp === 'B2C') {
$filter['kundentyp'] = $filter_kundentyp;
}
llxHeader('', $langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorschlagsliste'));
print load_fiche_titre(
$langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorschlagsliste'),
mahnungSetupLink(),
'fa-envelope-open-text'
);
// --- Filter-Form ---
print '
';
if ($mode === 'vorschlag') {
renderVorschlagsliste($db, $filter);
} else {
renderArchiv($db, $filter);
}
llxFooter();
$db->close();
/**
* Rendert die Vorschlagsliste auf Basis von MahnungVorschlag.
*
* @param DoliDB $db
* @param array $filter
* @return void
*/
function renderVorschlagsliste($db, $filter)
{
global $langs, $user;
$service = new MahnungVorschlag($db);
// Fehler und Leerstand strikt trennen: getVorschlaege() liefert false, wenn die
// Vorschlagslogik gar nicht laufen konnte (SQL-Fehler oder keine aktive Mahnstufe
// konfiguriert) — array() dagegen bedeutet "es gibt wirklich nichts zu mahnen".
// Ohne diese Prüfung würde empty(false) === true den Fehler als "keine
// überfälligen Rechnungen" tarnen, also als "alles bezahlt".
//
// Reihenfolge ist wichtig: getUebersprungeneRechnungen() setzt $service->error
// zurück, deshalb wird das Ergebnis von getVorschlaege() ZUERST ausgewertet.
$rows = $service->getVorschlaege($filter);
if ($rows === false) {
print ''.dol_escape_htmltag(mahnungVorschlagFehlertext($service)).'
';
return;
}
// Die Übersprungen-Tabelle ist reine Diagnose. Scheitert nur sie, bleibt die
// Vorschlagsliste nutzbar — Hinweis ausgeben und ohne die Diagnose weitermachen.
$skipped = $service->getUebersprungeneRechnungen($filter);
if ($skipped === false) {
print ''.dol_escape_htmltag(
$langs->transnoentities('MahnungUebersprungenFehler', mahnungVorschlagFehlertext($service))
).'
';
$skipped = array();
}
if (empty($rows) && empty($skipped)) {
print ''.$langs->trans('MahnungKeineUeberfaelligen').'
';
return;
}
if (empty($rows)) {
print ''.$langs->trans('MahnungKeineUeberfaelligen').'
';
renderUebersprungeneTabelle($skipped);
return;
}
$canWrite = $user->hasRight('mahnung', 'write');
print '';
renderUebersprungeneTabelle($skipped);
}
/**
* Diagnose-Tabelle: überfällige Rechnungen, die aktuell nicht vorgeschlagen werden,
* inklusive Grund (Wartefrist, Stufen ausgeschöpft, ...).
*
* @param array $skipped
* @return void
*/
function renderUebersprungeneTabelle($skipped)
{
global $langs;
if (empty($skipped)) {
return;
}
print '
'.$langs->trans('MahnungUebersprungen').' ('.count($skipped).')
';
print ''.$langs->trans('MahnungUebersprungenHint').'
';
print '';
print '';
print '| '.$langs->trans('MahnungRechnung').' | ';
print ''.$langs->trans('MahnungKunde').' | ';
print ''.$langs->trans('MahnungKontakt').' | ';
print ''.$langs->trans('MahnungFaelligkeitAlt').' | ';
print ''.$langs->trans('MahnungTageVerzug').' | ';
print ''.$langs->trans('MahnungBetragOffen').' | ';
print ''.$langs->trans('MahnungLetzteMahnung').' | ';
print ''.$langs->trans('MahnungSkipGrund').' | ';
print '
';
foreach ($skipped as $r) {
print '';
print '| '.dol_escape_htmltag($r['facture_ref']).' | ';
print ''.dol_escape_htmltag($r['soc_nom']).' | ';
print ''.renderKontaktIcons($r['soc_phone'] ?? '', $r['soc_email'] ?? '').' | ';
print ''.dol_print_date($r['facture_date_lim_reglement'], 'day').' | ';
print ''.((int) $r['tage_verzug']).' | ';
print ''.price($r['betrag_offen']).' | ';
print ''.renderLetzteMahnungZelle($r).' | ';
print ''.dol_escape_htmltag((string) $r['skip_reason']).' | ';
print '
';
}
print '
';
}
/**
* Rendert Kontakt-Icons (Telefon + Mail) mit Direktlinks.
*
* @param string $phone
* @param string $email
* @return string HTML
*/
function renderKontaktIcons($phone, $email)
{
$out = '';
$phone = trim((string) $phone);
$email = trim((string) $email);
if ($phone !== '') {
$telHref = preg_replace('/[^0-9+]/', '', $phone);
$out .= ''
.img_picto('', 'fa-phone').'';
}
if ($email !== '') {
$out .= ''
.img_picto('', 'fa-envelope').'';
}
if ($out === '') {
$out = '—';
}
return $out;
}
/**
* Zelle "Letzte Mahnung" für Vorschlags- und Übersprungen-Tabelle.
*
* WICHTIG — KEIN Truthy-Check auf die Stufennummer: Stufennummern sind frei
* konfigurierbar und 0 (kostenlose Zahlungserinnerung) ist eine gültige Stufe.
* Mit `$r['letzte_mahnung_stufe'] ? ... : '—'` stand nach einer versendeten
* Erinnerung ein Strich in der Zelle, also "noch nie gemahnt" — genau der
* Zustand, an dem Eddy die Wartefrist bis zur Folgestufe abliest.
* MahnungVorschlag liefert null, wenn es wirklich keine Vormahnung gibt.
*
* @param array $r Vorschlags-/Skip-Zeile aus MahnungVorschlag
* @return string HTML
*/
function renderLetzteMahnungZelle(array $r)
{
global $langs;
if (!isset($r['letzte_mahnung_stufe'])) {
return '—';
}
$datum = !empty($r['letzte_mahnung_datum']) ? dol_print_date($r['letzte_mahnung_datum'], 'day') : '';
// transnoentities() statt trans(): trans() liefert HTML-Entities, die zusammen
// mit einem zusätzlichen dol_escape_htmltag() doppelt kodiert würden.
// Beide Platzhalter sind hier unkritisch (int bzw. formatiertes Datum).
return $langs->transnoentities('MahnungStufeAmDatum', ((int) $r['letzte_mahnung_stufe']), $datum);
}
/**
* Zelle "Vorgeschlagene Stufe" inkl. Badge kostenlos/kostenpflichtig.
*
* Ob eine Stufe die kostenlose Zahlungserinnerung ist, entscheidet ausschließlich
* das Flag ist_erinnerung der Stufe (von MahnungVorschlag als
* 'vorgeschlagene_stufe_ist_erinnerung' mitgeliefert) — NICHT die Stufennummer.
* Ohne das Badge war der Liste nicht anzusehen, ob ein Häkchen eine kostenlose
* E-Mail-Erinnerung oder eine kostenpflichtige Mahnung mit Gebühr, §288-Pauschale
* und Verzugszinsen auslöst.
*
* Farbskala bewusst identisch zu core/boxes/box_mahnung_offen.php.
*
* @param array $r Vorschlags-Zeile aus MahnungVorschlag
* @return string HTML
*/
function renderVorschlagStufeZelle(array $r)
{
global $langs;
// Nummer und Bezeichnung stehen zusammen im Badge — kein zweites Etikett
// daneben, das nur dasselbe Wort wiederholt (siehe lib/mahnung_ui.lib.php).
return mahnungStufeBadge(
(int) $r['vorgeschlagene_stufe'],
isset($r['vorgeschlagene_stufe_label']) ? (string) $r['vorgeschlagene_stufe_label'] : '',
!empty($r['vorgeschlagene_stufe_ist_erinnerung'])
);
}
/**
* Baut den USC-Filter für das Kunden-Dropdown passend zum gewählten Kundentyp.
*
* Muss deckungsgleich zu MahnungVorschlag::ermittleKundentyp() bleiben: dort ist
* ein Kunde B2B, sobald tva_intra ODER siret ODER siren gefüllt ist oder fk_typent
* auf einen Code der B2B-Whitelist zeigt. Der Dropdown-Filter schränkte bis hierher
* nur über tva_intra ein — dadurch tauchten bei Kundentyp "B2C" Kleinunternehmer
* ohne USt-IdNr. im Dropdown auf, die die Vorschlagslogik anschließend als B2B
* wieder herausfilterte: der Kunde war wählbar, die Liste danach leer.
*
* WICHTIG: select_thirdparty_list() erwartet das Universal-Search-Criteria-Format
* (siehe forgeSQLFromUniversalSearchCriteria), NICHT plain SQL. Syntax
* (feld:operator:wert), verknüpft mit AND/OR, Klammerung ist erlaubt. Der
* Tabellen-Alias der Kundentabelle ist dort immer 's'.
*
* Bewusst nur die Operatoren =, !=, is und isnot: IN/NOTIN wären kürzer, sind aber
* je nach Dolibarr-Version unterschiedlich implementiert, und ein nicht erkannter
* Operator endet in einem SQL-Syntaxfehler mit HTTP 500 statt in einer leeren Liste.
*
* @param DoliDB $db
* @param string $kundentyp '' | 'B2B' | 'B2C'
* @return string USC-Filterstring, '' = kein Filter
*/
function mahnungBaueKundentypSocFilter($db, $kundentyp)
{
if ($kundentyp !== 'B2B' && $kundentyp !== 'B2C') {
return '';
}
$typentIds = mahnungB2bTypentIds($db);
if ($kundentyp === 'B2B') {
// Eines der Merkmale genügt -> ODER-Verknüpfung
$teile = array(
"((s.tva_intra:isnot:NULL) AND (s.tva_intra:!=:''))",
"((s.siret:isnot:NULL) AND (s.siret:!=:''))",
"((s.siren:isnot:NULL) AND (s.siren:!=:''))",
);
foreach ($typentIds as $id) {
$teile[] = "(s.fk_typent:=:".((int) $id).")";
}
return '('.implode(' OR ', $teile).')';
}
// B2C ist die Negation: KEIN einziges B2B-Merkmal darf zutreffen.
$teile = array(
"((s.tva_intra:is:NULL) OR (s.tva_intra:=:''))",
"((s.siret:is:NULL) OR (s.siret:=:''))",
"((s.siren:is:NULL) OR (s.siren:=:''))",
);
foreach ($typentIds as $id) {
// fk_typent IS NULL muss explizit mit rein: "fk_typent != 5" ist bei NULL
// nicht wahr, sondern NULL — der Kunde fiele sonst aus der B2C-Liste heraus.
$teile[] = "((s.fk_typent:is:NULL) OR (s.fk_typent:!=:".((int) $id)."))";
}
return '('.implode(' AND ', $teile).')';
}
/**
* Löst die B2B-Typ-Codes der Whitelist auf ihre IDs in llx_c_typent auf.
*
* Quelle ist dieselbe wie in MahnungVorschlag::getB2bTypentCodes(): die Konstante
* MAHNUNG_B2B_TYPENT_CODES, ersatzweise MahnungVorschlag::B2B_TYPENT_CODES_DEFAULT.
* Ein leerer Wert schaltet die fk_typent-Auswertung ab — dann bleibt auch der
* Dropdown-Filter bei tva_intra/siret/siren. Der Wert wird von Hand gepflegt,
* deshalb tolerant einlesen (trimmen, auf Großschreibung normalisieren).
*
* llx_c_typent wird bewusst OHNE "active = 1" abgefragt: ermittleKundentyp() joint
* die Tabelle ebenfalls ohne Active-Filter. Ein deaktivierter Typ würde sonst in der
* Auswertung B2B ergeben, im Dropdown aber fehlen — genau die Divergenz, die hier
* beseitigt werden soll.
*
* @param DoliDB $db
* @return int[] leeres Array = fk_typent nicht auswerten
*/
function mahnungB2bTypentIds($db)
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$cache = array();
$codesQuoted = array();
$raw = getDolGlobalString('MAHNUNG_B2B_TYPENT_CODES', MahnungVorschlag::B2B_TYPENT_CODES_DEFAULT);
foreach (explode(',', (string) $raw) as $code) {
$code = strtoupper(trim($code));
if ($code !== '') {
$codesQuoted[] = "'".$db->escape($code)."'";
}
}
if (empty($codesQuoted)) {
return $cache;
}
$sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_typent";
$sql .= " WHERE code IN (".implode(',', $codesQuoted).")";
$resql = $db->query($sql);
if (!$resql) {
// Kein harter Abbruch: ohne die IDs bleibt der Dropdown-Filter bei
// tva_intra/siret/siren, die Liste selbst funktioniert weiter.
dol_syslog('mahnung/list.php: Auflösung der B2B-Typ-Codes fehlgeschlagen: '.$db->lasterror(), LOG_ERR);
return $cache;
}
while ($obj = $db->fetch_object($resql)) {
$cache[] = (int) $obj->id;
}
$db->free($resql);
return $cache;
}
/**
* Rendert das Archiv aller bestehenden Mahnvorgänge.
*
* @param DoliDB $db
* @param array $filter
* @return void
*/
function renderArchiv($db, $filter)
{
global $langs;
$mahnungObj = new Mahnung($db);
$archivFilter = array();
if (isset($filter['stufe'])) {
$archivFilter['stufe'] = $filter['stufe'];
}
$mahnungen = $mahnungObj->fetchAll('date_mahnung', 'DESC', 200, 0, $archivFilter);
// fetchAll() liefert bei einem SQL-Fehler -1 statt array(). empty(-1) ist false,
// der Wert liefe also ungeprüft in die foreach-Schleifen (PHP-Warning + leere
// Tabelle). Fehler und Leerstand deshalb auch hier trennen.
if (!is_array($mahnungen)) {
$detail = !empty($mahnungObj->error) ? (string) $mahnungObj->error : '';
print ''.dol_escape_htmltag($langs->transnoentities('MahnungArchivFehler', $detail)).'
';
return;
}
if (empty($mahnungen)) {
print ''.$langs->trans('MahnungKeineVorgaenge').'
';
return;
}
// Refs für alle in der Liste vorkommenden Rechnungen+Kunden in zwei Queries holen
$socIds = array();
$factIds = array();
foreach ($mahnungen as $m) {
$socIds[(int) $m->fk_soc] = true;
$factIds[(int) $m->fk_facture] = true;
}
$socMap = array();
if (!empty($socIds)) {
$resql = $db->query("SELECT rowid, nom FROM ".MAIN_DB_PREFIX."societe WHERE rowid IN (".implode(',', array_keys($socIds)).")");
while ($resql && ($r = $db->fetch_object($resql))) {
$socMap[(int) $r->rowid] = $r->nom;
}
}
$factMap = array();
if (!empty($factIds)) {
$resql = $db->query("SELECT rowid, ref FROM ".MAIN_DB_PREFIX."facture WHERE rowid IN (".implode(',', array_keys($factIds)).")");
while ($resql && ($r = $db->fetch_object($resql))) {
$factMap[(int) $r->rowid] = $r->ref;
}
}
print '';
print '';
print '| '.$langs->trans('MahnungRef').' | ';
print ''.$langs->trans('MahnungRechnung').' | ';
print ''.$langs->trans('MahnungKunde').' | ';
print ''.$langs->trans('MahnungStufe').' | ';
print ''.$langs->trans('MahnungDatum').' | ';
print ''.$langs->trans('MahnungBetragOffen').' | ';
print ''.$langs->trans('MahnungGebuehr').' | ';
print ''.$langs->trans('MahnungVerzugszinsen').' | ';
print ''.$langs->trans('MahnungSumme').' | ';
print ''.$langs->trans('Status').' | ';
print '
';
foreach ($mahnungen as $m) {
$factureRef = $factMap[(int) $m->fk_facture] ?? ('#'.(int) $m->fk_facture);
$socName = $socMap[(int) $m->fk_soc] ?? ('#'.(int) $m->fk_soc);
print '';
print '| '.dol_escape_htmltag($m->ref).' | ';
print ''.dol_escape_htmltag($factureRef).' | ';
print ''.dol_escape_htmltag($socName).' | ';
print ''.((int) $m->stufe).' | ';
print ''.dol_print_date($m->date_mahnung, 'day').' | ';
print ''.price($m->betrag_offen).' | ';
print ''.price((float) $m->mahngebuehr + (float) $m->pauschale_b2b).' | ';
print ''.price($m->verzugszinsen).' | ';
print ''.price($m->summe_mahnung).' | ';
print ''.dol_escape_htmltag($m->getStatusLabel()).' | ';
print '
';
}
print '
';
}