All checks were successful
Deploy mahnung / deploy (push) Successful in 14s
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>
336 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|