mahnung/class/mahnungvorschlag.class.php
Eduard Wisch 10cf41a687
All checks were successful
Deploy mahnung / deploy (push) Successful in 14s
i18n: Alle Texte über $langs->trans() — ~100 neue Sprachschlüssel de_DE + en_US [deploy]
Umlaute in allen lang-Dateien korrigiert. Alle hardcodierten deutschen Strings
in 22 PHP-Dateien durch $langs->trans('Key') ersetzt. Neue Schlüssel für
Cron-Meldungen, Dokument-Aktionen, Bonität, Vorschlag-Status, Template-Vars u.a.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-13 16:25:50 +02:00

336 lines
10 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.
*/
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
class MahnungVorschlag
{
/** @var DoliDB */
public $db;
/** @var int */
public $entity;
/** @var MahnungStufe[] indexed by stufe (1..3) */
private $stufen = array();
/**
* @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,
* 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 1..3),
* vorgeschlagene_stufe_label (string)
*
* @param array $filter Optional: 'soc_id', 'min_tage_verzug', 'max_tage_verzug', 'stufe',
* 'min_betrag' (float), 'kundentyp' ('B2B'|'B2C')
* @return array
*/
public function getVorschlaege(array $filter = array())
{
$this->loadStufen();
if (empty($this->stufen)) {
return array();
}
$today = dol_now();
$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.phone AS soc_phone, s.email AS soc_email";
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc";
$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) {
dol_syslog('MahnungVorschlag::getVorschlaege SQL-Fehler: '.$this->db->lasterror(), LOG_ERR);
return array();
}
$result = array();
while ($obj = $this->db->fetch_object($resql)) {
$row = $this->buildVorschlag($obj, $today);
if ($row === null) {
continue;
}
if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) {
continue;
}
if (isset($filter['max_tage_verzug']) && $row['tage_verzug'] > (int) $filter['max_tage_verzug']) {
continue;
}
if (isset($filter['stufe']) && $filter['stufe'] !== '' && (int) $row['vorgeschlagene_stufe'] !== (int) $filter['stufe']) {
continue;
}
if (isset($filter['min_betrag']) && (float) $row['betrag_offen'] < (float) $filter['min_betrag']) {
continue;
}
if (!empty($filter['kundentyp']) && $row['kundentyp'] !== $filter['kundentyp']) {
continue;
}
$result[] = $row;
}
$this->db->free($resql);
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
*/
public function getUebersprungeneRechnungen(array $filter = array())
{
$rows = $this->buildAlleVorschlaege($filter);
$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(), zusätzlich:
* skip_reason (string|null)
*
* @param array $filter
* @return array
*/
public function buildAlleVorschlaege(array $filter = array())
{
$this->loadStufen();
if (empty($this->stufen)) {
return array();
}
$today = dol_now();
$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.phone AS soc_phone, s.email AS soc_email";
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc";
$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)";
$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) {
dol_syslog('MahnungVorschlag::buildAlleVorschlaege SQL-Fehler: '.$this->db->lasterror(), LOG_ERR);
return array();
}
$result = array();
while ($obj = $this->db->fetch_object($resql)) {
$row = $this->buildVorschlag($obj, $today, true);
if ($row === null) {
continue;
}
if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) {
continue;
}
if (isset($filter['min_betrag']) && (float) $row['betrag_offen'] < (float) $filter['min_betrag']) {
continue;
}
if (!empty($filter['kundentyp']) && $row['kundentyp'] !== $filter['kundentyp']) {
continue;
}
$result[] = $row;
}
$this->db->free($resql);
return $result;
}
/**
* Berechnet für eine einzelne Rechnung, ob/wozu eine Mahnung vorgeschlagen wird.
*
* @param object $factureObj DB-Reihe aus facture+societe
* @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 = !empty($factureObj->tva_intra) ? Mahnung::KUNDENTYP_B2B : Mahnung::KUNDENTYP_B2C;
// Letzte aktive Mahnung zur Rechnung holen
$lastMahnung = (new Mahnung($this->db))->fetchLastByFacture((int) $factureObj->facture_id);
// Nächste Stufe ermitteln
$proposedStufe = null;
$skipReason = null;
global $langs;
$langs->load('mahnung@mahnung');
if ($lastMahnung === null) {
$frist1 = isset($this->stufen[1]) ? (int) $this->stufen[1]->frist_tage : 0;
if (!isset($this->stufen[1])) {
$skipReason = $langs->trans('MahnungVorschlagStufeNichtKonfiguriert');
} elseif ($tageVerzug >= $frist1) {
$proposedStufe = 1;
} else {
$skipReason = $langs->trans('MahnungVorschlagFristNichtErreicht', $frist1, $tageVerzug);
}
} else {
$lastStufe = (int) $lastMahnung->stufe;
$nextStufe = $lastStufe + 1;
if ($lastStufe >= 3 || !isset($this->stufen[$nextStufe])) {
$skipReason = $langs->trans('MahnungVorschlagAlleStufenAusgeschoepft', $lastStufe);
} else {
$wartefrist = isset($this->stufen[$lastStufe]) ? (int) $this->stufen[$lastStufe]->neue_frist_tage : 7;
$tageSeitMahnung = (int) floor(($today - $lastMahnung->date_mahnung) / 86400);
if ($tageSeitMahnung >= $wartefrist) {
$proposedStufe = $nextStufe;
} else {
$skipReason = $langs->trans('MahnungVorschlagWartefristLaeuft', $lastStufe, $tageSeitMahnung, $wartefrist);
}
}
}
// 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;
}
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_phone' => $factureObj->soc_phone ?? '',
'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' => $proposedStufe !== null ? $this->stufen[$proposedStufe]->label : null,
'skip_reason' => $skipReason,
);
}
/**
* 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);
}
/**
* Stufen einmal in $this->stufen[1..3] cachen.
*/
private function loadStufen()
{
if (!empty($this->stufen)) {
return;
}
$so = new MahnungStufe($this->db);
foreach ($so->fetchAllActive() as $s) {
$this->stufen[(int) $s->stufe] = $s;
}
}
/**
* Gibt die geladene MahnungStufe (1..3) zurück oder null.
*
* @param int $stufe
* @return MahnungStufe|null
*/
public function getStufe($stufe)
{
$this->loadStufen();
return $this->stufen[(int) $stufe] ?? null;
}
}