Bonitaets-Workflow: Warnbox Kundenkarte + Uneinbringlich-Button Stufe 3 [deploy]
All checks were successful
Deploy mahnung / deploy (push) Successful in 13s
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:
parent
e833891804
commit
3a49c67fbd
6 changed files with 253 additions and 0 deletions
10
CHANGELOG.md
10
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.
|
||||
|
|
|
|||
76
card.php
76
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 '<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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class modMahnung extends DolibarrModules
|
|||
'data' => array(
|
||||
'invoicecard',
|
||||
'thirdpartycard',
|
||||
'ordercard',
|
||||
),
|
||||
'entity' => '0',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#
|
||||
|
|
|
|||
Loading…
Reference in a new issue