diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7d0beb..4070f7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/card.php b/card.php
index 71ef9d0..65b1a0f 100644
--- a/card.php
+++ b/card.php
@@ -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 '
';
@@ -509,6 +560,31 @@ if ($user->hasRight('mahnung', 'write')) {
print $langs->trans('MahnungGenerate');
print ' ';
}
+// "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 '
';
+ print img_picto('', 'warning', 'class="pictofixedwidth"');
+ print $langs->trans('MahnungUneinbringlich');
+ print '';
+ } else {
+ // Rechnung ist schon als abandoned markiert — Info anzeigen
+ print '
'.dol_escape_htmltag($langs->trans('MahnungRechnungBereitsUneinbringlich')).'';
+ }
+}
if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung', 'delete')) {
print '
';
print $langs->trans('MahnungStornieren');
diff --git a/class/actions_mahnung.class.php b/class/actions_mahnung.class.php
index 295ae7b..1422d55 100644
--- a/class/actions_mahnung.class.php
+++ b/class/actions_mahnung.class.php
@@ -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 '';
+ print '
';
+ print img_picto('', 'warning', 'class="pictofixedwidth"');
+ print dol_escape_htmltag($langs->trans('MahnungBonitaetWarnung')).'
';
+ print '
';
+ print '| '.((int) $info['anzahl']).' '.dol_escape_htmltag($langs->trans($info['anzahl'] === 1 ? 'MahnungBonitaetRechnungSing' : 'MahnungBonitaetRechnungPlur')).' | ';
+ print ''.price($info['summe']).' '.dol_escape_htmltag($langs->trans('MahnungBonitaetGesamtAusfall')).' | ';
+ if (!empty($info['letztes_datum'])) {
+ print ''.dol_escape_htmltag($langs->trans('MahnungBonitaetLetzteAm')).' '.dol_print_date($info['letztes_datum'], 'day').' | ';
+ }
+ print '
';
+ // Link zur Detail-Liste (gefilterter Mahnung-Archiv-View)
+ $listUrl = DOL_URL_ROOT.'/compta/facture/list.php?socid='.((int) $object->id).'&search_status=3';
+ print '
';
+ print '
';
+
+ 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 '';
+ print img_picto('', 'warning', 'class="pictofixedwidth"');
+ print ''.dol_escape_htmltag($langs->trans('MahnungBonitaetWarnungKurz')).': ';
+ print sprintf(
+ $langs->trans('MahnungBonitaetKundeHat'),
+ (int) $info['anzahl'],
+ price($info['summe'])
+ );
+ print '
';
+
+ 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,
+ );
+ }
}
diff --git a/core/modules/modMahnung.class.php b/core/modules/modMahnung.class.php
index aa09982..1cd5e5a 100644
--- a/core/modules/modMahnung.class.php
+++ b/core/modules/modMahnung.class.php
@@ -79,6 +79,7 @@ class modMahnung extends DolibarrModules
'data' => array(
'invoicecard',
'thirdpartycard',
+ 'ordercard',
),
'entity' => '0',
),
diff --git a/langs/de_DE/mahnung.lang b/langs/de_DE/mahnung.lang
index 3955e76..3cc95dc 100644
--- a/langs/de_DE/mahnung.lang
+++ b/langs/de_DE/mahnung.lang
@@ -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
#
diff --git a/langs/en_US/mahnung.lang b/langs/en_US/mahnung.lang
index ab96da6..29c7f6c 100644
--- a/langs/en_US/mahnung.lang
+++ b/langs/en_US/mahnung.lang
@@ -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
#