From 3a49c67fbd1659a13d7de7acf7e34800f0f6e758 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Mon, 11 May 2026 13:17:44 +0200 Subject: [PATCH] Bonitaets-Workflow: Warnbox Kundenkarte + Uneinbringlich-Button Stufe 3 [deploy] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 10 +++ card.php | 76 ++++++++++++++++++ class/actions_mahnung.class.php | 128 ++++++++++++++++++++++++++++++ core/modules/modMahnung.class.php | 1 + langs/de_DE/mahnung.lang | 19 +++++ langs/en_US/mahnung.lang | 19 +++++ 6 files changed, 253 insertions(+) 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 ''; + + 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 #