Bonitaets-Workflow: Warnbox Kundenkarte + Uneinbringlich-Button Stufe 3 [deploy]
All checks were successful
Deploy mahnung / deploy (push) Successful in 13s

Bonitaets-Anzeige:
- Hook tabContentViewThirdparty rendert prominente rote Warnbox auf der
  Kundenkarte wenn fk_statut=3 + close_code=badcustomer existiert. Zeigt
  Anzahl, Gesamtsumme, Datum letzter Abschreibung + Link zur Detail-Liste.
- Hook formObjectOptions zeigt kompakte Warn-Zeile auf ordercard und
  invoicecard wenn der Kunde Forderungsausfaelle hat.
- ordercard zum module_parts.hooks.data ergaenzt.

Uneinbringlich-Button:
- Auf Mahnung-Karten der Stufe 3, Status >= ERSTELLT, nicht storniert,
  Rechnung noch nicht abandoned.
- Bestaetigungs-Dialog mit Begruendungs-Textfeld (Default-Text setzt
  das aktuelle Datum ein).
- Ruft Facture::setCanceled mit CommonInvoice::CLOSECODE_BADDEBT.
- Mahnung wird storniert + Begruendung in note_private festgehalten.

Steuer-Modul kompatibel: EÜR liest nur llx_paiement (keine Zahlung =
keine Einnahme), UStVA filtert fk_statut IN (1,2) — abandoned Rechnungen
werden automatisch korrekt ausgeschlossen. Bei Ist-Versteuerung damit
buchhalterisch sauber, kein manueller Eingriff noetig.

Lang-Keys: 16 neu (de_DE + en_US) fuer Bonitaets-Box + Uneinbringlich-Workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-11 13:17:44 +02:00
parent e833891804
commit 3a49c67fbd
6 changed files with 253 additions and 0 deletions

View file

@ -2,6 +2,16 @@
## [Unreleased]
### Bonitaet / Forderungsausfall-Workflow
- Neuer Hook `tabContentViewThirdparty` rendert auf der Kundenkarte eine **prominente rote Warnbox**, wenn der Kunde abgeschriebene Rechnungen (`fk_statut=3` + `close_code='badcustomer'`) hat. Zeigt Anzahl, Gesamtsumme, Datum der letzten Abschreibung + Link zur Detail-Liste.
- Neuer Hook `formObjectOptions` zeigt eine kompakte Warn-Zeile bei Auftrags-/Rechnungs-Karten ("ordercard", "invoicecard"), wenn der Kunde Forderungsausfaelle hat — Bonitaets-Pruefung vor neuem Geschaeft.
- Neuer Hook-Kontext `ordercard` zum Modul-Descriptor ergaenzt.
- Neuer Button **"Als uneinbringlich klassifizieren"** auf Mahnung-Karten der Stufe 3 (Status ≥ ERSTELLT, nicht storniert, Rechnung nicht bereits abandoned).
- Bestaetigungs-Dialog mit Begruendungs-Textfeld (Default: "Mahnverfahren erfolglos abgeschlossen am ...")
- Ruft `Facture::setCanceled($user, CommonInvoice::CLOSECODE_BADDEBT, $note)` → Rechnung auf `fk_statut=3` + `close_code='badcustomer'`
- Mahnung wird zugleich storniert und die Begruendung in `note_private` festgehalten
- Steuerlich passt das: Dolibarrs Steuer-Modul (EÜR) ignoriert abandoned Rechnungen automatisch (UStVA filtert `fk_statut IN (1,2)`; EÜR liest nur `llx_paiement` = tatsaechliche Zahlungen). Bei Ist-Versteuerung ist damit alles korrekt — keine separate Buchung noetig.
### UX-Fixes (Vorschlagsliste)
- Kundentyp-Filter (B2B/B2C) wird jetzt direkt an `select_company()` durchgereicht — wenn B2C gewaehlt ist, zeigt das Kunden-Dropdown nur noch Drittparteien ohne TVA-Nummer (entsprechend umgekehrt fuer B2B). Filter nutzt Dolibarrs **Universal-Search-Criteria-Syntax** `(feld:operator:wert)` — plain SQL wuerde durch `forgeSQLFromUniversalSearchCriteria` fehlschlagen.
- Auto-Submit beim Wechsel des Kundentyps + automatisches Reset von `search_socid`, damit das Dropdown ohne extra "Suche"-Klick aktualisiert wird und keine ungueltige (im neuen Filter nicht enthaltene) Kunden-ID stehen bleibt.

View file

@ -100,6 +100,35 @@ if ($action === 'set_versand' && $user->hasRight('mahnung', 'write')) {
exit;
}
// Rechnung als uneinbringlich klassifizieren (close_code='badcustomer')
// — endgueltiger Schritt nach erfolglosem Mahnverfahren / Vollstreckung.
if ($action === 'confirm_uneinbringlich' && $user->hasRight('mahnung', 'delete')) {
require_once DOL_DOCUMENT_ROOT.'/core/class/commoninvoice.class.php';
$note = trim((string) GETPOST('uneinbringlich_note', 'nohtml'));
if ($note === '') {
$note = 'Mahnverfahren erfolglos abgeschlossen am '.dol_print_date(dol_now(), 'day');
}
$facReload = new Facture($db);
if ($facReload->fetch((int) $mahnung->fk_facture) > 0) {
// Rechnung auf STATUS_ABANDONED (3) + close_code='badcustomer' setzen
$ret = $facReload->setCanceled($user, CommonInvoice::CLOSECODE_BADDEBT, $note);
if ($ret > 0) {
// Mahnung mitstornieren — Vorgang ist damit fachlich abgeschlossen
$mahnung->status = Mahnung::STATUS_STORNIERT;
$mahnung->note_private = trim(($mahnung->note_private ? $mahnung->note_private."\n\n" : '').'Uneinbringlich klassifiziert ('.dol_print_date(dol_now(), 'day').'): '.$note);
$mahnung->update($user);
setEventMessages($langs->trans('MahnungUneinbringlichErfolg'), null, 'mesgs');
} else {
setEventMessages($facReload->error ?: 'Fehler beim Klassifizieren', null, 'errors');
}
} else {
setEventMessages('Rechnung nicht ladbar', null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Sendungsnummer aus erkanntem Vorschlag uebernehmen
if ($action === 'apply_tracking' && $user->hasRight('mahnung', 'write')) {
$nr = trim((string) GETPOST('nr', 'alphanohtml'));
@ -488,6 +517,28 @@ if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung',
1
);
}
if ($action === 'ask_uneinbringlich') {
// Bestaetigungsdialog mit Eingabefeld fuer Begruendung
$formquestion = array(
array(
'type' => 'textarea',
'name' => 'uneinbringlich_note',
'label' => $langs->trans('MahnungUneinbringlichBegruendung'),
'value' => 'Mahnverfahren erfolglos abgeschlossen am '.dol_print_date(dol_now(), 'day'),
'morecss' => 'minwidth500 quatrevingtpercent',
),
);
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id),
$langs->trans('MahnungUneinbringlichTitel'),
$langs->trans('MahnungUneinbringlichWarnung').' '.dol_escape_htmltag($mahnung->ref).'?',
'confirm_uneinbringlich',
$formquestion,
0,
1,
300
);
}
}
print '<br><div class="tabsAction">';
@ -509,6 +560,31 @@ if ($user->hasRight('mahnung', 'write')) {
print $langs->trans('MahnungGenerate');
print '</a> ';
}
// "Als uneinbringlich klassifizieren" — nur fuer Stufe-3-Mahnungen mit
// Status >= ERSTELLT (also nicht im Entwurf) und nicht bereits storniert.
// Setzt die Rechnung auf STATUS_ABANDONED mit close_code='badcustomer'.
if ($mahnung->status !== Mahnung::STATUS_STORNIERT
&& (int) $mahnung->stufe === 3
&& (int) $mahnung->status >= Mahnung::STATUS_ERSTELLT
&& $user->hasRight('mahnung', 'delete')
) {
// Pruefen ob Rechnung schon abandoned ist — dann Button verstecken
$facStatus = 0;
$facRes = $db->query("SELECT fk_statut, close_code FROM ".MAIN_DB_PREFIX."facture WHERE rowid = ".((int) $mahnung->fk_facture));
if ($facRes && ($facObj = $db->fetch_object($facRes))) {
$facStatus = (int) $facObj->fk_statut;
$db->free($facRes);
}
if ($facStatus !== 3) {
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=ask_uneinbringlich" title="'.dol_escape_htmltag($langs->trans('MahnungUneinbringlichHint')).'">';
print img_picto('', 'warning', 'class="pictofixedwidth"');
print $langs->trans('MahnungUneinbringlich');
print '</a>';
} else {
// Rechnung ist schon als abandoned markiert — Info anzeigen
print '<span class="opacitymedium" style="margin-left:8px;">'.dol_escape_htmltag($langs->trans('MahnungRechnungBereitsUneinbringlich')).'</span>';
}
}
if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung', 'delete')) {
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=confirm_storno">';
print $langs->trans('MahnungStornieren');

View file

@ -152,4 +152,132 @@ class ActionsMahnung
$db->free($resql);
return $count;
}
/**
* Hook tabContentViewThirdparty: rendert eine prominente Bonitaets-Warnbox auf
* der Kundenkarte, wenn fuer diesen Kunden uneinbringliche Forderungen existieren
* (fk_statut=3 abandoned + close_code='badcustomer').
*
* @param array $parameters
* @param CommonObject $object Societe
* @param string $action
* @param HookManager $hookmanager
* @return int 0
*/
public function tabContentViewThirdparty($parameters, &$object, &$action, $hookmanager)
{
global $langs, $db;
if (empty($object->id)) {
return 0;
}
$langs->load('mahnung@mahnung');
$info = $this->getBonitaetsInfo($db, (int) $object->id);
if (empty($info) || $info['anzahl'] <= 0) {
return 0;
}
// Rote Warnbox direkt unter dem Banner — vor dem Standard-Content
print '<div class="warning" style="margin:8px 0; padding:12px; border-left:5px solid #c33; background:#fee2; border-radius:4px;">';
print '<div style="font-size:1.1em; font-weight:bold; color:#c33; margin-bottom:6px;">';
print img_picto('', 'warning', 'class="pictofixedwidth"');
print dol_escape_htmltag($langs->trans('MahnungBonitaetWarnung')).'</div>';
print '<table class="noborder" style="margin:0;"><tr>';
print '<td><strong>'.((int) $info['anzahl']).'</strong> '.dol_escape_htmltag($langs->trans($info['anzahl'] === 1 ? 'MahnungBonitaetRechnungSing' : 'MahnungBonitaetRechnungPlur')).'</td>';
print '<td style="padding-left:30px;"><strong>'.price($info['summe']).'</strong> '.dol_escape_htmltag($langs->trans('MahnungBonitaetGesamtAusfall')).'</td>';
if (!empty($info['letztes_datum'])) {
print '<td style="padding-left:30px;" class="opacitymedium">'.dol_escape_htmltag($langs->trans('MahnungBonitaetLetzteAm')).' '.dol_print_date($info['letztes_datum'], 'day').'</td>';
}
print '</tr></table>';
// Link zur Detail-Liste (gefilterter Mahnung-Archiv-View)
$listUrl = DOL_URL_ROOT.'/compta/facture/list.php?socid='.((int) $object->id).'&search_status=3';
print '<div style="margin-top:6px;"><a href="'.$listUrl.'">'.dol_escape_htmltag($langs->trans('MahnungBonitaetAusfaelleAnzeigen')).' →</a></div>';
print '</div>';
return 0;
}
/**
* Hook addMoreActionsButtons: Warning-Banner bei der Aktions-Leiste falls
* der Kunde Forderungsausfaelle hat. Wird sowohl bei Auftrag (ordercard) als
* auch bei Rechnung (invoicecard) gezeigt.
*
* Ergaenzt addMoreActionsButtons (Mahnung-erstellen-Button) wird vor
* jenem Block ausgegeben.
*
* @param array $parameters
* @param CommonObject $object
* @param string $action
* @param HookManager $hookmanager
* @return int
*/
public function formObjectOptions($parameters, &$object, &$action, $hookmanager)
{
global $langs, $db;
$contexts = explode(':', $parameters['context'] ?? '');
if (!in_array('invoicecard', $contexts, true) && !in_array('ordercard', $contexts, true)) {
return 0;
}
if (empty($object->socid) && empty($object->fk_soc)) {
return 0;
}
$socid = (int) ($object->socid ?? $object->fk_soc);
if ($socid <= 0) {
return 0;
}
$langs->load('mahnung@mahnung');
$info = $this->getBonitaetsInfo($db, $socid);
if (empty($info) || $info['anzahl'] <= 0) {
return 0;
}
// Kompakte Warn-Zeile — unaufdringlich, aber sichtbar
print '<div class="warning" style="margin:8px 0; padding:8px 12px; border-left:4px solid #c33; background:#fef2f2; border-radius:3px;">';
print img_picto('', 'warning', 'class="pictofixedwidth"');
print '<strong>'.dol_escape_htmltag($langs->trans('MahnungBonitaetWarnungKurz')).':</strong> ';
print sprintf(
$langs->trans('MahnungBonitaetKundeHat'),
(int) $info['anzahl'],
price($info['summe'])
);
print '</div>';
return 0;
}
/**
* Liefert Anzahl, Summe und Datum der letzten als 'badcustomer' abandonierten
* Rechnungen fuer einen Kunden.
*
* @param DoliDB $db
* @param int $socid
* @return array{anzahl:int, summe:float, letztes_datum:int|null}|null
*/
private function getBonitaetsInfo($db, $socid)
{
if ($socid <= 0) {
return null;
}
$sql = "SELECT COUNT(*) AS nb, COALESCE(SUM(total_ttc), 0) AS summe, MAX(date_closing) AS letztes_datum";
$sql .= " FROM ".MAIN_DB_PREFIX."facture";
$sql .= " WHERE fk_soc = ".((int) $socid);
$sql .= " AND fk_statut = 3";
$sql .= " AND close_code = 'badcustomer'";
$sql .= " AND entity IN (".getEntity('facture').")";
$resql = $db->query($sql);
if (!$resql) {
return null;
}
$obj = $db->fetch_object($resql);
$db->free($resql);
return array(
'anzahl' => (int) $obj->nb,
'summe' => (float) $obj->summe,
'letztes_datum' => !empty($obj->letztes_datum) ? $db->jdate($obj->letztes_datum) : null,
);
}
}

View file

@ -79,6 +79,7 @@ class modMahnung extends DolibarrModules
'data' => array(
'invoicecard',
'thirdpartycard',
'ordercard',
),
'entity' => '0',
),

View file

@ -127,6 +127,25 @@ MahnungTrackingUebernommen = Sendungsnummer uebernommen
MahnungTrackingVerwerfen = Vorschlaege verwerfen
MahnungPdftotextMissing = pdftotext nicht im Container verfuegbar — PDFs koennen nicht durchsucht werden. Nur txt/html werden gescannt.
#
# Bonitaet / Forderungsausfall (Phase 6)
#
MahnungBonitaetWarnung = Forderungsausfaelle vorhanden — Bonitaet pruefen
MahnungBonitaetWarnungKurz = Bonitaet
MahnungBonitaetRechnungSing = abgeschriebene Rechnung
MahnungBonitaetRechnungPlur = abgeschriebene Rechnungen
MahnungBonitaetGesamtAusfall = gesamter Ausfall
MahnungBonitaetLetzteAm = Letzte Abschreibung am
MahnungBonitaetAusfaelleAnzeigen = Abgeschriebene Rechnungen anzeigen
MahnungBonitaetKundeHat = Dieser Kunde hat %d uneinbringliche Rechnung(en) in Hoehe von %s. Vor neuem Geschaeft Bonitaet pruefen.
MahnungUneinbringlich = Als uneinbringlich klassifizieren
MahnungUneinbringlichHint = Rechnung wird als abandoned + close_code='badcustomer' markiert. Endgueltiger Schritt nach erfolglosem Mahnverfahren / Vollstreckung.
MahnungUneinbringlichTitel = Forderung als uneinbringlich klassifizieren
MahnungUneinbringlichWarnung = Achtung: Dieser Schritt ist (fast) endgueltig. Die Rechnung wird auf STATUS_ABANDONED gesetzt und die Mahnung storniert. Bitte VORHER sicherstellen: Mahnbescheid + Vollstreckungsversuch erfolglos ODER Insolvenz nachgewiesen ODER Verjaehrung eingetreten. Weiter mit
MahnungUneinbringlichBegruendung = Begruendung (z.B. Vollstreckungsbescheid vom ..., Insolvenz-Aktenzeichen)
MahnungUneinbringlichErfolg = Rechnung als uneinbringlich klassifiziert + Mahnung storniert
MahnungRechnungBereitsUneinbringlich = Rechnung bereits abandoned
#
# Liste / Karte
#

View file

@ -127,6 +127,25 @@ MahnungTrackingUebernommen = Tracking number applied
MahnungTrackingVerwerfen = Dismiss suggestions
MahnungPdftotextMissing = pdftotext not available in container — PDFs cannot be scanned. Only txt/html will be processed.
#
# Credit standing / Bad debt (Phase 6)
#
MahnungBonitaetWarnung = Bad debts on record — verify credit standing
MahnungBonitaetWarnungKurz = Credit
MahnungBonitaetRechnungSing = written-off invoice
MahnungBonitaetRechnungPlur = written-off invoices
MahnungBonitaetGesamtAusfall = total loss
MahnungBonitaetLetzteAm = Last write-off on
MahnungBonitaetAusfaelleAnzeigen = Show written-off invoices
MahnungBonitaetKundeHat = This customer has %d unrecoverable invoice(s) totalling %s. Verify credit before new business.
MahnungUneinbringlich = Classify as unrecoverable
MahnungUneinbringlichHint = Invoice will be marked as abandoned + close_code='badcustomer'. Final step after unsuccessful collection / enforcement.
MahnungUneinbringlichTitel = Classify receivable as unrecoverable
MahnungUneinbringlichWarnung = Warning: This step is (almost) final. Invoice will be set to STATUS_ABANDONED and dunning cancelled. Please confirm beforehand: unsuccessful collection order + enforcement OR proven insolvency OR statute of limitations expired. Continue with
MahnungUneinbringlichBegruendung = Reason (e.g. enforcement order from ..., insolvency case ID)
MahnungUneinbringlichErfolg = Invoice classified as unrecoverable + dunning cancelled
MahnungRechnungBereitsUneinbringlich = Invoice already abandoned
#
# List / card
#