Phase 13/14 abgeschlossen. Der Mailversand war zuvor KAPUTT: ajax/sendmail.php
war bereits zur Funktionsbibliothek umgebaut, card.php postete aber weiterhin
per JS dagegen.
Mailformular (FormMail)
- card.php nutzt jetzt Dolibarrs Standard-Mailformular (action=presend/send):
Empfaenger (Firma + alle Ansprechpartner), Betreff, Text und Anhang sichtbar
und aenderbar. Gesendet wird ausschliesslich ueber mahnungSendeErinnerungsMail().
- Anhang = unveraenderte Original-Rechnungs-PDF, wird bei Bedarf nacherzeugt.
Eigener Parameter mailinit statt mode=init, weil get_form() bei mode=init die
Anhangsliste selbst leert.
- HTML-Mails: DolEditor im Setup + withfckeditor=-1 im Formular (folgt
FCKEDITOR_ENABLE_MAIL wie Dolibarrs eigene Mailvorlagen).
- Klartext bleibt Klartext: GETPOST('restricthtml') jagt jeden Nicht-HTML-Text
durch dol_nl2br() — mahnungBodyEntkleiden() nimmt nur dieses Artefakt zurueck
und laesst echte Formatierung unangetastet.
- Platzhalter jetzt auch in Dolibarr-Schreibweise (__REF__, __DATE_YMD__,
__AMOUNT_FORMATED__, __DATE_DUE_YMD__, __FRIST_TAGE__ ...), Liste sichtbar im Setup.
- Absender-Adresse und -Name konfigurierbar (MAHNUNG_EMAIL_SENDER[_NAME]).
- Erneuter Versand moeglich (force aus dem Status abgeleitet, nicht aus dem
Request — Doppelversand-Schutz bleibt wirksam).
Versandprotokoll (neue Tabelle llx_mahnung_mailprotokoll)
- Jede versendete Erinnerung wird mit Empfaenger, Kopie, Betreff, Text und
Anhangsnamen festgehalten, einsehbar unter Versandstatus. Historie statt
Spalten am Vorgang, weil erneut gesendet werden kann.
- Lazy-Migration legt die Tabelle an (DB_VERSION 0.4.0), kein Reaktivieren noetig.
Haertung nach Code-Review (21 bestaetigte Funde)
- Anhang liess sich nicht abwaehlen (wurde sofort wieder eingehaengt)
- Upload/Entfernen ohne Rechtepruefung; Temp-Verzeichnis pro Vorgang getrennt
- Teilzahlung zwischen Oeffnen und Senden fuehrt zurueck ins Formular
- Empfaenger: Semikolon-Trenner, keine stillen Verwerfungen, Dubletten, CR/LF
- CSRF: presend + Core-Dateiaktionen (confirm_deletefile, renamefile, sendit,
linkit) token-pflichtig
- Externe Benutzer sehen nur eigene Vorgaenge; Abschreiben verlangt facture.creer
UI
- Mahnstufe nur noch EINE Darstellung (Badge), Farbskala zentral in
lib/mahnung_ui.lib.php statt doppelt gepflegt
- Zahnrad oben rechts in die Einstellungen (nur mit Recht mahnung.setup)
- Original-Rechnung unter Verknuepfte Dokumente mit Vorschau, Groesse in KB
- PDF-Einleitungstext nur noch, wo ueberhaupt ein PDF entsteht
Sprachdateien de_DE/en_US deckungsgleich, 9 tote Keys entfernt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
667 lines
22 KiB
PHP
667 lines
22 KiB
PHP
<?php
|
|
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
|
*
|
|
* 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;
|
|
}
|
|
}
|
|
}
|