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>
628 lines
25 KiB
PHP
628 lines
25 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 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 '<form method="GET" action="'.$_SERVER['PHP_SELF'].'">';
|
|
print '<input type="hidden" name="mainmenu" value="billing">';
|
|
print '<input type="hidden" name="leftmenu" value="mahnung">';
|
|
print '<input type="hidden" name="mode" value="'.dol_escape_htmltag($mode).'">';
|
|
print '<table class="noborder centpercent"><tr class="liste_titre">';
|
|
print '<th>'.$langs->trans('MahnungStufe').'</th>';
|
|
print '<th>'.$langs->trans('MahnungTageVerzug').' (min)</th>';
|
|
print '<th>'.$langs->trans('MahnungBetragOffen').' (min)</th>';
|
|
print '<th>'.$langs->trans('MahnungKundentyp').'</th>';
|
|
print '<th>'.$langs->trans('MahnungKunde').'</th>';
|
|
print '<th></th></tr>';
|
|
print '<tr>';
|
|
|
|
// Mahnstufe — Optionen kommen aus der Konfiguration, NICHT hartkodiert 1/2/3.
|
|
// Stufennummern sind frei wählbar und Stufe 0 (Zahlungserinnerung) muss filterbar sein.
|
|
$stufenOptionen = array();
|
|
$stufenFilterObj = new MahnungStufe($db);
|
|
foreach ($stufenFilterObj->fetchAllActive() as $s) {
|
|
$stufenOptionen[(int) $s->stufe] = (string) $s->label;
|
|
}
|
|
// Eine bereits gewählte Stufe muss wählbar bleiben, auch wenn sie inzwischen
|
|
// deaktiviert wurde — sonst kippt der Filter beim nächsten Absenden stumm auf "Alle".
|
|
// Im Archiv-Modus gibt es zudem Mahnvorgänge zu nicht mehr aktiven Stufen.
|
|
if ((string) $filter_stufe !== '' && !isset($stufenOptionen[(int) $filter_stufe])) {
|
|
$stufenOptionen[(int) $filter_stufe] = '';
|
|
}
|
|
ksort($stufenOptionen, SORT_NUMERIC);
|
|
|
|
print '<td><select name="filter_stufe">';
|
|
print '<option value="">— '.$langs->trans('All').' —</option>';
|
|
foreach ($stufenOptionen as $st => $stLabel) {
|
|
print '<option value="'.((int) $st).'"'.((string) $filter_stufe === (string) $st ? ' selected' : '').'>';
|
|
print ((int) $st).($stLabel !== '' ? ' — '.dol_escape_htmltag($stLabel) : '');
|
|
print '</option>';
|
|
}
|
|
print '</select></td>';
|
|
|
|
// Mindestverzug
|
|
print '<td><input type="number" name="filter_minverzug" value="'.dol_escape_htmltag((string) $filter_minverzug).'" size="4"></td>';
|
|
|
|
// Mindestbetrag (in EUR, Komma erlaubt)
|
|
print '<td><input type="text" name="filter_minbetrag" value="'.dol_escape_htmltag((string) $filter_minbetrag).'" size="6" placeholder="0,00"> €</td>';
|
|
|
|
// Kundentyp — Auto-Submit beim Wechsel + Kunden-Auswahl zurücksetzen, damit das
|
|
// Kunden-Dropdown direkt mit dem neuen Typ-Filter neu geladen wird (alte Auswahl
|
|
// könnte im neuen Filter gar nicht mehr enthalten sein).
|
|
print '<td><select name="filter_kundentyp" onchange="';
|
|
print 'var s=this.form.elements[\'search_socid\']; if(s){s.value=\'\';} this.form.submit();';
|
|
print '">';
|
|
print '<option value="">— '.$langs->trans('All').' —</option>';
|
|
print '<option value="B2B"'.($filter_kundentyp === 'B2B' ? ' selected' : '').'>B2B</option>';
|
|
print '<option value="B2C"'.($filter_kundentyp === 'B2C' ? ' selected' : '').'>B2C</option>';
|
|
print '</select></td>';
|
|
|
|
// Kunden-Auswahl: Dolibarr-Standard select_company (Ajax wenn COMPANY_USE_SEARCH_TO_SELECT,
|
|
// sonst klassisches Dropdown). htmlname='search_socid' bleibt = Backward-Kompatibilität
|
|
// zu Direkt-Links (?search_socid=74) von der Kundenkarte. Wenn Kundentyp-Filter
|
|
// gesetzt ist, schränken wir die Dropdown-Liste passend ein — deckungsgleich mit
|
|
// MahnungVorschlag::ermittleKundentyp(), siehe mahnungBaueKundentypSocFilter().
|
|
$socFilter = mahnungBaueKundentypSocFilter($db, $filter_kundentyp);
|
|
print '<td>';
|
|
print $form->select_company(
|
|
$filter_socid, // selected
|
|
'search_socid', // htmlname (Hidden-Input-Name)
|
|
$socFilter, // filter im USC-Format, von Dolibarr in die WHERE eingebunden
|
|
'SelectThirdParty', // showempty (Translation-Key)
|
|
0, 0, array(), 0, 'minwidth250'
|
|
);
|
|
print '</td>';
|
|
|
|
print '<td><input type="submit" class="button" value="'.$langs->trans('Search').'"></td>';
|
|
print '</tr></table>';
|
|
print '</form><br>';
|
|
|
|
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 '<div class="error">'.dol_escape_htmltag(mahnungVorschlagFehlertext($service)).'</div>';
|
|
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 '<div class="warning">'.dol_escape_htmltag(
|
|
$langs->transnoentities('MahnungUebersprungenFehler', mahnungVorschlagFehlertext($service))
|
|
).'</div>';
|
|
$skipped = array();
|
|
}
|
|
|
|
if (empty($rows) && empty($skipped)) {
|
|
print '<div class="info">'.$langs->trans('MahnungKeineUeberfaelligen').'</div>';
|
|
return;
|
|
}
|
|
|
|
if (empty($rows)) {
|
|
print '<div class="info">'.$langs->trans('MahnungKeineUeberfaelligen').'</div>';
|
|
renderUebersprungeneTabelle($skipped);
|
|
return;
|
|
}
|
|
|
|
$canWrite = $user->hasRight('mahnung', 'write');
|
|
|
|
print '<form method="POST" action="'.DOL_URL_ROOT.'/custom/mahnung/ajax/createmahnung.php" id="formMahnung">';
|
|
print '<input type="hidden" name="token" value="'.newToken().'">';
|
|
print '<table class="noborder centpercent">';
|
|
print '<tr class="liste_titre">';
|
|
if ($canWrite) {
|
|
print '<th><input type="checkbox" id="chkAll"></th>';
|
|
}
|
|
print '<th>'.$langs->trans('MahnungRechnung').'</th>';
|
|
print '<th>'.$langs->trans('MahnungKunde').'</th>';
|
|
print '<th>'.$langs->trans('MahnungKontakt').'</th>';
|
|
print '<th>'.$langs->trans('MahnungKundentyp').'</th>';
|
|
print '<th>'.$langs->trans('MahnungFaelligkeitAlt').'</th>';
|
|
print '<th class="right">'.$langs->trans('MahnungTageVerzug').'</th>';
|
|
print '<th class="right">'.$langs->trans('MahnungBetragOffen').'</th>';
|
|
print '<th>'.$langs->trans('MahnungLetzteMahnung').'</th>';
|
|
print '<th>'.$langs->trans('MahnungVorgeschlageneStufe').'</th>';
|
|
print '</tr>';
|
|
|
|
$summeOffen = 0.0;
|
|
foreach ($rows as $r) {
|
|
print '<tr class="oddeven">';
|
|
if ($canWrite) {
|
|
print '<td><input type="checkbox" name="facture_ids[]" value="'.((int) $r['facture_id']).'" data-stufe="'.((int) $r['vorgeschlagene_stufe']).'"></td>';
|
|
}
|
|
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $r['facture_id']).'">'.dol_escape_htmltag($r['facture_ref']).'</a></td>';
|
|
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $r['soc_id']).'">'.dol_escape_htmltag($r['soc_nom']).'</a></td>';
|
|
print '<td class="nowrap">'.renderKontaktIcons($r['soc_phone'] ?? '', $r['soc_email'] ?? '').'</td>';
|
|
print '<td>'.dol_escape_htmltag($r['kundentyp']).'</td>';
|
|
print '<td>'.dol_print_date($r['facture_date_lim_reglement'], 'day').'</td>';
|
|
print '<td class="right">'.((int) $r['tage_verzug']).'</td>';
|
|
print '<td class="right">'.price($r['betrag_offen']).'</td>';
|
|
print '<td>'.renderLetzteMahnungZelle($r).'</td>';
|
|
print '<td>'.renderVorschlagStufeZelle($r).'</td>';
|
|
print '</tr>';
|
|
$summeOffen += (float) $r['betrag_offen'];
|
|
}
|
|
print '<tr class="liste_total"><td colspan="'.($canWrite ? 7 : 6).'" class="right">'.$langs->trans('Total').'</td>';
|
|
print '<td class="right">'.price($summeOffen).'</td><td colspan="2"></td></tr>';
|
|
print '</table>';
|
|
|
|
if ($canWrite) {
|
|
print '<br><div class="center">';
|
|
print '<button type="submit" class="button" name="action" value="bulk_create">'.$langs->trans('MahnungErstellen').'</button> ';
|
|
print '<button type="submit" class="button" name="action" value="bulk_sammelbrief" formaction="'.DOL_URL_ROOT.'/custom/mahnung/ajax/sammelbrief.php">'.$langs->trans('MahnungSammelbrief').'</button>';
|
|
print '</div>';
|
|
print '<script>document.getElementById("chkAll")?.addEventListener("change", function(e){';
|
|
print 'document.querySelectorAll("input[name=\'facture_ids[]\']").forEach(c => c.checked = e.target.checked);';
|
|
print '});</script>';
|
|
}
|
|
print '</form>';
|
|
|
|
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 '<br><h3>'.$langs->trans('MahnungUebersprungen').' ('.count($skipped).')</h3>';
|
|
print '<div class="opacitymedium">'.$langs->trans('MahnungUebersprungenHint').'</div>';
|
|
print '<table class="noborder centpercent">';
|
|
print '<tr class="liste_titre">';
|
|
print '<th>'.$langs->trans('MahnungRechnung').'</th>';
|
|
print '<th>'.$langs->trans('MahnungKunde').'</th>';
|
|
print '<th>'.$langs->trans('MahnungKontakt').'</th>';
|
|
print '<th>'.$langs->trans('MahnungFaelligkeitAlt').'</th>';
|
|
print '<th class="right">'.$langs->trans('MahnungTageVerzug').'</th>';
|
|
print '<th class="right">'.$langs->trans('MahnungBetragOffen').'</th>';
|
|
print '<th>'.$langs->trans('MahnungLetzteMahnung').'</th>';
|
|
print '<th>'.$langs->trans('MahnungSkipGrund').'</th>';
|
|
print '</tr>';
|
|
foreach ($skipped as $r) {
|
|
print '<tr class="oddeven">';
|
|
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $r['facture_id']).'">'.dol_escape_htmltag($r['facture_ref']).'</a></td>';
|
|
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $r['soc_id']).'">'.dol_escape_htmltag($r['soc_nom']).'</a></td>';
|
|
print '<td class="nowrap">'.renderKontaktIcons($r['soc_phone'] ?? '', $r['soc_email'] ?? '').'</td>';
|
|
print '<td>'.dol_print_date($r['facture_date_lim_reglement'], 'day').'</td>';
|
|
print '<td class="right">'.((int) $r['tage_verzug']).'</td>';
|
|
print '<td class="right">'.price($r['betrag_offen']).'</td>';
|
|
print '<td>'.renderLetzteMahnungZelle($r).'</td>';
|
|
print '<td><span class="opacitymedium">'.dol_escape_htmltag((string) $r['skip_reason']).'</span></td>';
|
|
print '</tr>';
|
|
}
|
|
print '</table>';
|
|
}
|
|
|
|
/**
|
|
* 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 .= '<a href="tel:'.dol_escape_htmltag($telHref).'" title="'.dol_escape_htmltag($phone).'" class="marginrightonly">'
|
|
.img_picto('', 'fa-phone').'</a>';
|
|
}
|
|
if ($email !== '') {
|
|
$out .= '<a href="mailto:'.dol_escape_htmltag($email).'" title="'.dol_escape_htmltag($email).'">'
|
|
.img_picto('', 'fa-envelope').'</a>';
|
|
}
|
|
if ($out === '') {
|
|
$out = '<span class="opacitymedium">—</span>';
|
|
}
|
|
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 '<span class="opacitymedium">—</span>';
|
|
}
|
|
|
|
$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 '<div class="error">'.dol_escape_htmltag($langs->transnoentities('MahnungArchivFehler', $detail)).'</div>';
|
|
return;
|
|
}
|
|
|
|
if (empty($mahnungen)) {
|
|
print '<div class="info">'.$langs->trans('MahnungKeineVorgaenge').'</div>';
|
|
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 '<table class="noborder centpercent">';
|
|
print '<tr class="liste_titre">';
|
|
print '<th>'.$langs->trans('MahnungRef').'</th>';
|
|
print '<th>'.$langs->trans('MahnungRechnung').'</th>';
|
|
print '<th>'.$langs->trans('MahnungKunde').'</th>';
|
|
print '<th>'.$langs->trans('MahnungStufe').'</th>';
|
|
print '<th>'.$langs->trans('MahnungDatum').'</th>';
|
|
print '<th class="right">'.$langs->trans('MahnungBetragOffen').'</th>';
|
|
print '<th class="right">'.$langs->trans('MahnungGebuehr').'</th>';
|
|
print '<th class="right">'.$langs->trans('MahnungVerzugszinsen').'</th>';
|
|
print '<th class="right">'.$langs->trans('MahnungSumme').'</th>';
|
|
print '<th>'.$langs->trans('Status').'</th>';
|
|
print '</tr>';
|
|
|
|
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 '<tr class="oddeven">';
|
|
print '<td><a href="'.DOL_URL_ROOT.'/custom/mahnung/card.php?id='.((int) $m->id).'">'.dol_escape_htmltag($m->ref).'</a></td>';
|
|
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $m->fk_facture).'">'.dol_escape_htmltag($factureRef).'</a></td>';
|
|
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $m->fk_soc).'">'.dol_escape_htmltag($socName).'</a></td>';
|
|
print '<td>'.((int) $m->stufe).'</td>';
|
|
print '<td>'.dol_print_date($m->date_mahnung, 'day').'</td>';
|
|
print '<td class="right">'.price($m->betrag_offen).'</td>';
|
|
print '<td class="right">'.price((float) $m->mahngebuehr + (float) $m->pauschale_b2b).'</td>';
|
|
print '<td class="right">'.price($m->verzugszinsen).'</td>';
|
|
print '<td class="right"><strong>'.price($m->summe_mahnung).'</strong></td>';
|
|
print '<td>'.dol_escape_htmltag($m->getStatusLabel()).'</td>';
|
|
print '</tr>';
|
|
}
|
|
print '</table>';
|
|
}
|