mahnung/class/mahnungvorschlag.class.php
Eduard Wisch 0244a5b07e feat(mahnung): Mailversand ueber FormMail, Versandprotokoll, HTML-Mails
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>
2026-07-22 21:31:25 +02:00

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;
}
}
}