* * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 3. */ /** * \file htdocs/custom/mahnung/class/mahnungvorschlag.class.php * \ingroup mahnung * \brief Service: überfällige Rechnungen einsammeln und je Rechnung * die nächste vorgeschlagene Mahnstufe ermitteln. * * Geteilte Logik zwischen Cron-Job (Ntfy-Push) und Vorschlagslisten-UI. * * Die Stufenlogik ist vollständig datengetrieben: es gibt keine hartkodierte * Stufe 1 und keine Obergrenze bei Stufe 3 mehr. Maßgeblich ist ausschließlich * das, was in llx_mahnung_stufe als aktiv konfiguriert ist — inklusive Stufe 0 * (kostenlose Zahlungserinnerung) und beliebig vieler weiterer Stufen. */ require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; class MahnungVorschlag { /** * Typ-Codes aus llx_c_typent, die für sich genommen belegen, dass der Kunde * KEIN Verbraucher im Sinne des §13 BGB ist (Whitelist, per Konstante * MAHNUNG_B2B_TYPENT_CODES überschreibbar). * * Bewusst NICHT enthalten: * - TE_SMALL ('TPE'/Kleinstunternehmen): in den Stammdaten tragen mehrere * reine Privatkunden diesen Typ. Wer dort wirklich Unternehmer ist, wird * ohnehin über tva_intra/siret/siren als B2B erkannt. * - TE_OTHER ('Autres'): Sammelposten ohne Aussage über die Verbrauchereigenschaft. * * Leerer Wert der Konstante = fk_typent wird gar nicht ausgewertet. */ const B2B_TYPENT_CODES_DEFAULT = 'TE_GROUP,TE_MEDIUM,TE_ADMIN,TE_WHOLE,TE_RETAIL'; /** @var DoliDB */ public $db; /** @var int */ public $entity; /** @var string Letzte Fehlermeldung (gesetzt wenn eine Methode false liefert) */ public $error = ''; /** @var string[] Alle Fehlermeldungen des letzten Aufrufs */ public $errors = array(); /** * Aktive Stufen, indiziert nach Stufennummer und aufsteigend sortiert. * Stufennummern sind frei konfigurierbar, 0 ist erlaubt. * * @var MahnungStufe[] */ private $stufen = array(); /** @var bool true = loadStufen() wurde bereits ausgeführt (auch bei leerem Ergebnis) */ private $stufenGeladen = false; /** @var string SQL-Fehler beim Laden der Stufen (leer = kein Fehler) */ private $stufenFehler = ''; /** @var string[]|null Aufgelöste B2B-Typ-Codes (null = noch nicht ausgewertet) */ private $b2bTypentCodes = null; /** * @param DoliDB $db */ public function __construct($db) { global $conf; $this->db = $db; $this->entity = $conf->entity; } /** * Liefert pro überfälliger Rechnung einen Vorschlag (oder überspringt sie, * wenn alle Stufen bereits durchlaufen sind oder die Wartefrist noch läuft). * * Rückgabe-Schlüssel je Eintrag: * facture_id, facture_ref, facture_date_lim_reglement (Unix), facture_total_ttc, * soc_id, soc_nom, soc_tva_intra, soc_siret, soc_siren, soc_typent_code, * soc_phone, soc_email, * kundentyp ('B2C'|'B2B'), * tage_verzug, * betrag_offen, * letzte_mahnung_id (int|null), letzte_mahnung_stufe (int|null), letzte_mahnung_datum (Unix|null), * vorgeschlagene_stufe (int|null — frei konfigurierbare Stufennummer, 0 möglich), * vorgeschlagene_stufe_label (string|null), * vorgeschlagene_stufe_ist_erinnerung (int 0|1), * skip_reason (string|null) * * WICHTIG für Aufrufer: der Rückgabewert ist false, wenn etwas schiefgelaufen ist * (SQL-Fehler oder keine aktive Stufe konfiguriert). Ein leeres array() bedeutet * dagegen echten Leerstand — "es gibt nichts zu mahnen". Die beiden Fälle dürfen * NICHT gleich behandelt werden (sonst räumt z.B. der Cron bei einem DB-Ausfall * die Benachrichtigungen weg, als wäre alles bezahlt). * * @param array $filter Optional: 'soc_id', 'min_tage_verzug', 'max_tage_verzug', 'stufe', * 'min_betrag' (float), 'kundentyp' ('B2B'|'B2C') * @return array|false false bei Fehler ($this->error gesetzt) */ public function getVorschlaege(array $filter = array()) { $this->resetError(); if (!$this->loadStufen()) { return false; } $today = dol_now(); $rows = $this->ladeUeberfaelligeRechnungen($filter, $today, 'getVorschlaege'); if ($rows === false) { return false; } $result = array(); foreach ($rows as $obj) { $row = $this->buildVorschlag($obj, $today); if ($row === null) { continue; } if (!$this->passtZuBasisFilter($row, $filter)) { continue; } if (isset($filter['max_tage_verzug']) && $row['tage_verzug'] > (int) $filter['max_tage_verzug']) { continue; } // Achtung: Stufe 0 ist eine gültige Stufennummer — deshalb wird gegen '' // geprüft (Filter nicht gesetzt) und nicht per empty(). if (isset($filter['stufe']) && $filter['stufe'] !== '' && (int) $row['vorgeschlagene_stufe'] !== (int) $filter['stufe']) { continue; } $result[] = $row; } return $result; } /** * Liefert alle überfälligen Rechnungen, für die aktuell KEIN Vorschlag passt, * inkl. Begründung (skip_reason). Diagnose-Hilfe für das UI. * * @param array $filter (siehe getVorschlaege) * @return array|false false bei Fehler ($this->error gesetzt) */ public function getUebersprungeneRechnungen(array $filter = array()) { $rows = $this->buildAlleVorschlaege($filter); if ($rows === false) { // $this->error wurde bereits von buildAlleVorschlaege() gesetzt return false; } $skipped = array(); foreach ($rows as $r) { if ($r['vorgeschlagene_stufe'] === null) { $skipped[] = $r; } } return $skipped; } /** * Liefert sowohl vorgeschlagene als auch übersprungene Rechnungen in einem Durchlauf. * Result-Schlüssel je Eintrag wie bei getVorschlaege(). * * Der Stufen-Filter wird hier bewusst NICHT angewandt: übersprungene Rechnungen * haben keine Zielstufe und würden sonst komplett herausfallen. * * @param array $filter * @return array|false false bei Fehler ($this->error gesetzt) */ public function buildAlleVorschlaege(array $filter = array()) { $this->resetError(); if (!$this->loadStufen()) { return false; } $today = dol_now(); $rows = $this->ladeUeberfaelligeRechnungen($filter, $today, 'buildAlleVorschlaege'); if ($rows === false) { return false; } $result = array(); foreach ($rows as $obj) { $row = $this->buildVorschlag($obj, $today, true); if ($row === null) { continue; } if (!$this->passtZuBasisFilter($row, $filter)) { continue; } $result[] = $row; } return $result; } /** * Kleinste aktive Stufennummer — der Einstiegspunkt für Rechnungen ohne Vormahnung. * Ist Stufe 0 (Zahlungserinnerung) aktiv, wird 0 geliefert. * * @return int|null null wenn keine Stufe konfiguriert/ladbar ist ($this->error gesetzt) */ public function minStufe() { if (!$this->loadStufen()) { return null; } $keys = array_keys($this->stufen); return (int) $keys[0]; } /** * Nächste aktive Stufe nach einer bereits gemahnten Stufe. * Lücken werden übersprungen (0 -> 1 -> 5 -> 9 ist zulässig), eine Obergrenze * gibt es nicht — Ende ist erreicht, wenn keine höhere aktive Stufe existiert. * * @param int $lastStufe Zuletzt gemahnte Stufennummer * @return int|null null = keine weitere Stufe vorhanden */ public function naechsteStufeNach($lastStufe) { if (!$this->loadStufen()) { return null; } foreach (array_keys($this->stufen) as $nr) { if ((int) $nr > (int) $lastStufe) { return (int) $nr; } } return null; } /** * Gibt die geladene MahnungStufe zurück oder null. * * @param int $stufe Stufennummer (frei konfigurierbar, 0 möglich) * @return MahnungStufe|null */ public function getStufe($stufe) { if (!$this->loadStufen()) { return null; } return isset($this->stufen[(int) $stufe]) ? $this->stufen[(int) $stufe] : null; } /** * Alle aktiven Stufen, aufsteigend nach Stufennummer indiziert. * Praktisch für Aufrufer, die dynamisch über die konfigurierten Stufen laufen * müssen (Cron-Zähler, Setup-/Filter-Dropdowns). * * @return MahnungStufe[] leeres Array wenn nichts konfiguriert ist */ public function getAlleStufen() { if (!$this->loadStufen()) { return array(); } return $this->stufen; } /** * Überfällige, offene Rechnungen der Entity laden. * * Die Kundentyp-Erkennung braucht neben tva_intra auch siret/siren und den * Typ-Code aus llx_c_typent — deshalb der LEFT JOIN. Ein Kunde ohne USt-IdNr. * ist nicht automatisch eine Privatperson (Kleinunternehmer §19 UStG!). * * @param array $filter siehe getVorschlaege() * @param int $today Unix-Zeit * @param string $context Aufrufer-Name für das Syslog * @return object[]|false DB-Zeilen oder false bei SQL-Fehler */ private function ladeUeberfaelligeRechnungen(array $filter, $today, $context) { $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.fk_statut,"; $sql .= " s.nom AS soc_nom, s.tva_intra, s.siret AS soc_siret, s.siren AS soc_siren,"; $sql .= " s.phone AS soc_phone, s.email AS soc_email,"; $sql .= " te.code AS typent_code"; $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; $sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_typent as te ON te.id = s.fk_typent"; $sql .= " WHERE f.entity = ".((int) $this->entity); $sql .= " AND f.fk_statut = 1"; $sql .= " AND f.paye = 0"; $sql .= " AND f.type IN (0, 2, 3)"; // Standard, Avoir, Acompte (keine Replacements) $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) { $dbError = $this->db->lasterror(); dol_syslog('MahnungVorschlag::'.$context.' SQL-Fehler: '.$dbError, LOG_ERR); $this->ladeSprache(); global $langs; $this->setError($langs->trans('MahnungVorschlagSqlFehler', $dbError)); return false; } $rows = array(); while ($obj = $this->db->fetch_object($resql)) { $rows[] = $obj; } $this->db->free($resql); return $rows; } /** * Filter, die für Vorschlagsliste UND Übersprungen-Liste gleichermaßen gelten. * * @param array $row * @param array $filter * @return bool true = Zeile behalten */ private function passtZuBasisFilter(array $row, array $filter) { if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) { return false; } if (isset($filter['min_betrag']) && (float) $row['betrag_offen'] < (float) $filter['min_betrag']) { return false; } if (!empty($filter['kundentyp']) && $row['kundentyp'] !== $filter['kundentyp']) { return false; } return true; } /** * Berechnet für eine einzelne Rechnung, ob/wozu eine Mahnung vorgeschlagen wird. * * @param object $factureObj DB-Reihe aus facture+societe+c_typent * @param int $today Unix-Zeit * @param bool $includeSkipped true = liefert auch übersprungene mit skip_reason * @return array|null */ private function buildVorschlag($factureObj, $today, $includeSkipped = false) { $dateLim = $this->db->jdate($factureObj->date_lim_reglement); if (empty($dateLim)) { return null; } $tageVerzug = (int) floor(($today - $dateLim) / 86400); if ($tageVerzug < 0) { $tageVerzug = 0; } $kundentyp = $this->ermittleKundentyp($factureObj); // Letzte aktive Mahnung zur Rechnung holen $lastMahnung = (new Mahnung($this->db))->fetchLastByFacture((int) $factureObj->facture_id); $this->ladeSprache(); global $langs; // Zielstufe rein datengetrieben ermitteln $entscheidung = $this->ermittleZielstufe($lastMahnung, $tageVerzug, $today); $proposedStufe = $entscheidung['stufe']; $skipReason = $entscheidung['grund']; // 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 = $langs->trans('MahnungVorschlagBetragNull'); } if ($proposedStufe === null && !$includeSkipped) { return null; } $zielStufeObj = ($proposedStufe !== null && isset($this->stufen[$proposedStufe])) ? $this->stufen[$proposedStufe] : null; return array( 'facture_id' => (int) $factureObj->facture_id, 'facture_ref' => $factureObj->facture_ref, 'facture_date_lim_reglement' => $dateLim, 'facture_total_ttc' => (float) $factureObj->total_ttc, 'soc_id' => (int) $factureObj->fk_soc, 'soc_nom' => $factureObj->soc_nom, 'soc_tva_intra' => $factureObj->tva_intra, 'soc_siret' => isset($factureObj->soc_siret) ? $factureObj->soc_siret : '', 'soc_siren' => isset($factureObj->soc_siren) ? $factureObj->soc_siren : '', 'soc_typent_code' => isset($factureObj->typent_code) ? $factureObj->typent_code : '', 'soc_phone' => isset($factureObj->soc_phone) ? $factureObj->soc_phone : '', 'soc_email' => isset($factureObj->soc_email) ? $factureObj->soc_email : '', 'kundentyp' => $kundentyp, 'tage_verzug' => $tageVerzug, 'betrag_offen' => $betragOffen, 'letzte_mahnung_id' => $lastMahnung ? (int) $lastMahnung->id : null, 'letzte_mahnung_stufe' => $lastMahnung ? (int) $lastMahnung->stufe : null, 'letzte_mahnung_datum' => $lastMahnung ? $lastMahnung->date_mahnung : null, 'vorgeschlagene_stufe' => $proposedStufe, 'vorgeschlagene_stufe_label' => $zielStufeObj !== null ? $zielStufeObj->label : null, 'vorgeschlagene_stufe_ist_erinnerung' => ($zielStufeObj !== null && $zielStufeObj->istErinnerung()) ? 1 : 0, 'skip_reason' => $skipReason, ); } /** * Kern der Stufenlogik — komplett datengetrieben, ohne hartkodierte Stufennummern. * * 1. Ohne Vormahnung: kleinste aktive Stufe (minStufe()). Dadurch ist Stufe 0 * (kostenlose Zahlungserinnerung) überhaupt erst erreichbar. * 2. Mit Vormahnung: naechsteStufeNach() — Lücken in der Nummerierung werden * übersprungen, eine Obergrenze gibt es nicht. * 3. Wartefrist ist immer frist_tage der ZIELstufe (nicht neue_frist_tage der * Vorstufe — dadurch war frist_tage der Folgestufen bisher wirkungslos). * 4. Bezugspunkt der Wartefrist ist date_versand der letzten Mahnung, ersatzweise * date_mahnung. Ohne diesen Fallback würden Bestandsmahnungen ohne * date_versand dauerhaft blockieren. * * @param Mahnung|null $lastMahnung Letzte nicht stornierte Mahnung der Rechnung * @param int $tageVerzug Tage seit Fälligkeit der Rechnung * @param int $today Unix-Zeit * @return array array('stufe' => int|null, 'grund' => string|null) */ private function ermittleZielstufe($lastMahnung, $tageVerzug, $today) { global $langs; $this->ladeSprache(); // --- Einstieg: noch keine Mahnung zu dieser Rechnung --- if ($lastMahnung === null) { $zielStufe = $this->minStufe(); if ($zielStufe === null) { // loadStufen() hat $this->error bereits gesetzt return array('stufe' => null, 'grund' => $this->error); } $frist = (int) $this->stufen[$zielStufe]->frist_tage; if ($tageVerzug < $frist) { return array( 'stufe' => null, 'grund' => $langs->trans('MahnungVorschlagFristNichtErreichtStufe', $zielStufe, $frist, $tageVerzug), ); } return array('stufe' => $zielStufe, 'grund' => null); } // --- Folgestufe --- $lastStufe = (int) $lastMahnung->stufe; $zielStufe = $this->naechsteStufeNach($lastStufe); if ($zielStufe === null) { return array( 'stufe' => null, 'grund' => $langs->trans('MahnungVorschlagAlleStufenAusgeschoepft', $lastStufe), ); } $wartefrist = (int) $this->stufen[$zielStufe]->frist_tage; // Bezugspunkt: tatsächlicher Versand, ersatzweise das Mahndatum. $basis = !empty($lastMahnung->date_versand) ? $lastMahnung->date_versand : $lastMahnung->date_mahnung; if (empty($basis)) { // Weder Versand- noch Mahndatum vorhanden (defekter Datensatz): // konservativ ab jetzt rechnen statt die Rechnung stumm durchzulassen. $basis = $today; } $tageSeit = (int) floor(($today - $basis) / 86400); if ($tageSeit < 0) { $tageSeit = 0; } if ($tageSeit < $wartefrist) { return array( 'stufe' => null, 'grund' => $langs->trans('MahnungVorschlagWartefristZielstufe', $zielStufe, $tageSeit, $wartefrist, $lastStufe), ); } return array('stufe' => $zielStufe, 'grund' => null); } /** * Kundentyp B2B/B2C bestimmen. * * B2B, sobald EINES dieser Merkmale greift: * - USt-IdNr. (tva_intra) gefüllt * - siret oder siren gefüllt (Handelsregister-/Unternehmensnummer) * - fk_typent zeigt auf einen der eindeutigen Unternehmens-/Behördentypen * aus B2B_TYPENT_CODES_DEFAULT bzw. MAHNUNG_B2B_TYPENT_CODES * Erst wenn nichts davon zutrifft: B2C. * * Warum überhaupt mehr als tva_intra: Kleinunternehmer nach §19 UStG haben oft * gar keine USt-IdNr. — die Erkennung allein über tva_intra hat sie als * Privatkunden eingestuft und damit den falschen Verzugszins (5 statt * 9 Prozentpunkte) sowie keine 40-EUR-Pauschale nach §288 Abs. 5 angesetzt. * * Warum fk_typent als WHITELIST und nicht als "alles außer TE_PRIVATE": * eine Blacklist stuft jeden Datensatz mit gepflegtem Typ automatisch zum * Unternehmer hoch — auch natürliche Personen, die versehentlich TE_SMALL * ('TPE') oder TE_OTHER ('Autres') tragen. Gegen die Stammdaten geprüft * hätte das mehrere reine Privatkunden zu B2B gemacht, mit 40-EUR-Pauschale * und 9 statt 5 Prozentpunkten Verzugszins — bei einem Verbraucher rechtlich * nicht haltbar. Die Fehlerrichtung ist bewusst asymmetrisch gewählt: * ein zu Unrecht als B2C geführter Unternehmer kostet Eddy etwas Zins, * ein zu Unrecht als B2B gemahnter Verbraucher kostet ihn die Forderung. * * @param object $factureObj DB-Reihe * @return string 'B2B'|'B2C' */ private function ermittleKundentyp($factureObj) { $tvaIntra = isset($factureObj->tva_intra) ? trim((string) $factureObj->tva_intra) : ''; $siret = isset($factureObj->soc_siret) ? trim((string) $factureObj->soc_siret) : ''; $siren = isset($factureObj->soc_siren) ? trim((string) $factureObj->soc_siren) : ''; $typent = isset($factureObj->typent_code) ? trim((string) $factureObj->typent_code) : ''; if ($tvaIntra !== '' || $siret !== '' || $siren !== '') { return Mahnung::KUNDENTYP_B2B; } if ($typent !== '' && in_array(strtoupper($typent), $this->getB2bTypentCodes(), true)) { return Mahnung::KUNDENTYP_B2B; } return Mahnung::KUNDENTYP_B2C; } /** * Typ-Codes, die allein schon B2B begründen. Einmal je Instanz aufgelöst. * * Anpassbar über die Konstante MAHNUNG_B2B_TYPENT_CODES (kommaseparierte * Codes aus llx_c_typent). Sind die Stammdaten sauber gepflegt, kann dort * z.B. TE_SMALL ergänzt werden; ein leerer Wert schaltet die Auswertung * von fk_typent komplett ab (dann zählen nur noch tva_intra/siret/siren). * * Der Wert wird von Hand gepflegt, deshalb tolerant einlesen: Leerzeichen * werden getrimmt und die Codes auf Großschreibung normalisiert. * * @return string[] */ private function getB2bTypentCodes() { if ($this->b2bTypentCodes === null) { $this->b2bTypentCodes = array(); $raw = getDolGlobalString('MAHNUNG_B2B_TYPENT_CODES', self::B2B_TYPENT_CODES_DEFAULT); foreach (explode(',', (string) $raw) as $code) { $code = strtoupper(trim($code)); if ($code !== '') { $this->b2bTypentCodes[] = $code; } } } return $this->b2bTypentCodes; } /** * Offener Betrag = total_ttc - SUM(paiement.amount). * * @param int $factureId * @param float $totalTtc * @return float */ private function getBetragOffen($factureId, $totalTtc) { $sql = "SELECT COALESCE(SUM(pf.amount), 0) AS gezahlt"; $sql .= " FROM ".MAIN_DB_PREFIX."paiement_facture as pf"; $sql .= " WHERE pf.fk_facture = ".((int) $factureId); $resql = $this->db->query($sql); if (!$resql) { return (float) $totalTtc; } $obj = $this->db->fetch_object($resql); $this->db->free($resql); return round(((float) $totalTtc) - ((float) $obj->gezahlt), 2); } /** * Aktive Stufen einmal laden und nach Stufennummer aufsteigend indizieren. * Unterscheidet sauber zwischen SQL-Fehler und "nichts konfiguriert" — * beides ist ein Fehlerzustand für die Vorschlagslogik, aber mit * unterschiedlicher Meldung. * * @return bool false = keine nutzbare Stufenkonfiguration ($this->error gesetzt) */ private function loadStufen() { if (!$this->stufenGeladen) { $this->stufenGeladen = true; $so = new MahnungStufe($this->db); $liste = $so->fetchAllActive(); if (!empty($so->error)) { // fetchAllActive() liefert bei einem SQL-Fehler ebenfalls array() — // nur $so->error unterscheidet Fehler von Leerstand. $this->stufenFehler = $so->error; dol_syslog('MahnungVorschlag::loadStufen SQL-Fehler: '.$so->error, LOG_ERR); } else { foreach ($liste as $s) { $this->stufen[(int) $s->stufe] = $s; } // fetchAllActive() sortiert bereits, ksort sichert die Reihenfolge // unabhängig davon ab — minStufe()/naechsteStufeNach() verlassen sich darauf. ksort($this->stufen, SORT_NUMERIC); } } $this->ladeSprache(); global $langs; if ($this->stufenFehler !== '') { $this->setError($langs->trans('MahnungVorschlagSqlFehler', $this->stufenFehler)); return false; } if (empty($this->stufen)) { $this->setError($langs->trans('MahnungVorschlagKeineStufenKonfiguriert')); return false; } return true; } /** * Sprachdatei des Moduls sicherstellen (Mehrfachaufruf ist unkritisch, * $langs->load() cached intern). * * @return void */ private function ladeSprache() { global $langs; $langs->load('mahnung@mahnung'); } /** * Fehlerkanal zurücksetzen — an jedem öffentlichen Einstiegspunkt. * * @return void */ private function resetError() { $this->error = ''; $this->errors = array(); } /** * Fehlermeldung setzen (error + errors[]). * * @param string $msg * @return void */ private function setError($msg) { $this->error = (string) $msg; if (!in_array($this->error, $this->errors, true)) { $this->errors[] = $this->error; } } }