Archiv zeigt Kundennamen, Vorschlagsliste mit Skip-Diagnose [deploy]
All checks were successful
Deploy mahnung / deploy (push) Successful in 13s

- list.php Archiv: Rechnungs-Ref + Kunden-Name per Bulk-Query (statt rowid).
  Mahnung-Ref klickt zur Detailansicht (card.php).
- MahnungVorschlag: neue buildAlleVorschlaege()/getUebersprungeneRechnungen()
  liefern auch ueberfaellige Rechnungen ohne aktuellen Vorschlag inkl. skip_reason
  (Wartefrist laeuft, Stufen ausgeschoepft, Frist Stufe 1 nicht erreicht, ...).
- list.php Vorschlagsliste: zweite Tabelle "Aktuell uebersprungen" mit Grund.
  Erklaert warum ueberfaellige Rechnungen nicht in der Vorschlagsliste auftauchen.
- Lang-Keys MahnungUebersprungen/Hint/SkipGrund (DE+EN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-07 12:16:20 +02:00
parent d1db85322b
commit d889fb25a5
4 changed files with 186 additions and 19 deletions

View file

@ -108,14 +108,89 @@ class MahnungVorschlag
return $result;
}
/**
* Liefert alle ueberfaelligen Rechnungen, fuer die aktuell KEIN Vorschlag passt,
* inkl. Begruendung (skip_reason). Diagnose-Hilfe fuer das UI.
*
* @param array $filter (siehe getVorschlaege)
* @return array
*/
public function getUebersprungeneRechnungen(array $filter = array())
{
$rows = $this->buildAlleVorschlaege($filter);
$skipped = array();
foreach ($rows as $r) {
if ($r['vorgeschlagene_stufe'] === null) {
$skipped[] = $r;
}
}
return $skipped;
}
/**
* Liefert sowohl vorgeschlagene als auch uebersprungene Rechnungen in einem Durchlauf.
* Result-Schluessel je Eintrag wie bei getVorschlaege(), zusaetzlich:
* skip_reason (string|null)
*
* @param array $filter
* @return array
*/
public function buildAlleVorschlaege(array $filter = array())
{
$this->loadStufen();
if (empty($this->stufen)) {
return array();
}
$today = dol_now();
$sql = "SELECT f.rowid AS facture_id, f.ref AS facture_ref, f.date_lim_reglement,";
$sql .= " f.total_ttc, f.fk_soc, f.paye, f.statut,";
$sql .= " s.nom AS soc_nom, s.tva_intra";
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc";
$sql .= " WHERE f.entity = ".((int) $this->entity);
$sql .= " AND f.statut = 1";
$sql .= " AND f.paye = 0";
$sql .= " AND f.type IN (0, 2, 3)";
$sql .= " AND f.date_lim_reglement IS NOT NULL";
$sql .= " AND f.date_lim_reglement < '".$this->db->idate($today)."'";
if (!empty($filter['soc_id'])) {
$sql .= " AND f.fk_soc = ".((int) $filter['soc_id']);
}
$sql .= " ORDER BY f.date_lim_reglement ASC";
$resql = $this->db->query($sql);
if (!$resql) {
dol_syslog('MahnungVorschlag::buildAlleVorschlaege SQL-Fehler: '.$this->db->lasterror(), LOG_ERR);
return array();
}
$result = array();
while ($obj = $this->db->fetch_object($resql)) {
$row = $this->buildVorschlag($obj, $today, true);
if ($row === null) {
continue;
}
if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) {
continue;
}
$result[] = $row;
}
$this->db->free($resql);
return $result;
}
/**
* Berechnet fuer eine einzelne Rechnung, ob/wozu eine Mahnung vorgeschlagen wird.
*
* @param object $factureObj DB-Reihe aus facture+societe
* @param int $today Unix-Zeit
* @param bool $includeSkipped true = liefert auch uebersprungene mit skip_reason
* @return array|null
*/
private function buildVorschlag($factureObj, $today)
private function buildVorschlag($factureObj, $today, $includeSkipped = false)
{
$dateLim = $this->db->jdate($factureObj->date_lim_reglement);
if (empty($dateLim)) {
@ -133,33 +208,43 @@ class MahnungVorschlag
// Naechste Stufe ermitteln
$proposedStufe = null;
$skipReason = null;
if ($lastMahnung === null) {
// Noch nichts gemahnt -> Stufe 1, sobald frist_tage erreicht
if (isset($this->stufen[1]) && $tageVerzug >= (int) $this->stufen[1]->frist_tage) {
$frist1 = isset($this->stufen[1]) ? (int) $this->stufen[1]->frist_tage : 0;
if (!isset($this->stufen[1])) {
$skipReason = 'Stufe 1 nicht konfiguriert';
} elseif ($tageVerzug >= $frist1) {
$proposedStufe = 1;
} else {
$skipReason = 'Frist Stufe 1 ('.$frist1.' Tage) noch nicht erreicht (Verzug '.$tageVerzug.' Tage)';
}
} else {
// Bereits gemahnt -> naechste Stufe wenn Wartefrist seit letzter Mahnung abgelaufen
$lastStufe = (int) $lastMahnung->stufe;
$nextStufe = $lastStufe + 1;
if ($lastStufe >= 3 || !isset($this->stufen[$nextStufe])) {
return null; // alle Stufen ausgeschoepft
$skipReason = 'Alle Mahnstufen ausgeschoepft (zuletzt Stufe '.$lastStufe.')';
} else {
$wartefrist = isset($this->stufen[$lastStufe]) ? (int) $this->stufen[$lastStufe]->neue_frist_tage : 7;
$tageSeitMahnung = (int) floor(($today - $lastMahnung->date_mahnung) / 86400);
if ($tageSeitMahnung >= $wartefrist) {
$proposedStufe = $nextStufe;
} else {
$skipReason = 'Wartefrist nach Stufe '.$lastStufe.' laeuft noch ('.$tageSeitMahnung.'/'.$wartefrist.' Tage)';
}
}
// Wartefrist: neue_frist_tage der zuletzt gemahnten Stufe
$wartefrist = isset($this->stufen[$lastStufe]) ? (int) $this->stufen[$lastStufe]->neue_frist_tage : 7;
$tageSeitMahnung = (int) floor(($today - $lastMahnung->date_mahnung) / 86400);
if ($tageSeitMahnung >= $wartefrist) {
$proposedStufe = $nextStufe;
}
}
if ($proposedStufe === null) {
return null;
}
// Offenen Betrag berechnen (total_ttc - Summe aller Zahlungen)
$betragOffen = $this->getBetragOffen((int) $factureObj->facture_id, (float) $factureObj->total_ttc);
if ($betragOffen <= 0) {
if (!$includeSkipped) {
return null;
}
$proposedStufe = null;
$skipReason = 'Offener Betrag <= 0 (vermutl. komplett bezahlt, paye-Flag noch nicht gesetzt)';
}
if ($proposedStufe === null && !$includeSkipped) {
return null;
}
@ -178,7 +263,8 @@ class MahnungVorschlag
'letzte_mahnung_stufe' => $lastMahnung ? (int) $lastMahnung->stufe : null,
'letzte_mahnung_datum' => $lastMahnung ? $lastMahnung->date_mahnung : null,
'vorgeschlagene_stufe' => $proposedStufe,
'vorgeschlagene_stufe_label' => $this->stufen[$proposedStufe]->label,
'vorgeschlagene_stufe_label' => $proposedStufe !== null ? $this->stufen[$proposedStufe]->label : null,
'skip_reason' => $skipReason,
);
}

View file

@ -85,6 +85,9 @@ MahnungErstellen = Mahnung erstellen
MahnungSammelbrief = Sammelbrief erzeugen
MahnungStornieren = Stornieren
MahnungKeineUeberfaelligen = Keine ueberfaelligen Rechnungen vorhanden.
MahnungUebersprungen = Aktuell uebersprungene Rechnungen
MahnungUebersprungenHint = Diese Rechnungen sind ueberfaellig, werden aber aktuell nicht vorgeschlagen (Wartefrist laeuft noch oder alle Mahnstufen ausgeschoepft).
MahnungSkipGrund = Grund
#
# Setup-Seite

View file

@ -85,6 +85,9 @@ MahnungErstellen = Create dunning
MahnungSammelbrief = Generate bulk letter
MahnungStornieren = Cancel
MahnungKeineUeberfaelligen = No overdue invoices found.
MahnungUebersprungen = Currently skipped invoices
MahnungUebersprungenHint = These invoices are overdue but currently not proposed (waiting period running or all dunning stages exhausted).
MahnungSkipGrund = Reason
#
# Setup page

View file

@ -119,9 +119,16 @@ function renderVorschlagsliste($db, $filter)
$service = new MahnungVorschlag($db);
$rows = $service->getVorschlaege($filter);
$skipped = $service->getUebersprungeneRechnungen($filter);
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;
}
@ -175,6 +182,49 @@ function renderVorschlagsliste($db, $filter)
print '});</script>';
}
print '</form>';
renderUebersprungeneTabelle($skipped);
}
/**
* Diagnose-Tabelle: ueberfaellige Rechnungen, die aktuell nicht vorgeschlagen werden,
* inklusive Grund (Wartefrist, Stufen ausgeschoepft, ...).
*
* @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('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>'.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>'.($r['letzte_mahnung_stufe'] ? 'Stufe '.((int) $r['letzte_mahnung_stufe']).' am '.dol_print_date($r['letzte_mahnung_datum'], 'day') : '—').'</td>';
print '<td class="opacitymedium">'.dol_escape_htmltag((string) $r['skip_reason']).'</td>';
print '</tr>';
}
print '</table>';
}
/**
@ -200,6 +250,28 @@ function renderArchiv($db, $filter)
return;
}
// Refs fuer 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>';
@ -215,10 +287,13 @@ function renderArchiv($db, $filter)
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>'.dol_escape_htmltag($m->ref).'</td>';
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $m->fk_facture).'">#'.((int) $m->fk_facture).'</a></td>';
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $m->fk_soc).'">#'.((int) $m->fk_soc).'</a></td>';
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>';