All checks were successful
Deploy mahnung / deploy (push) Successful in 14s
Vollstaendiges 3-stufiges Mahnwesen nach BGB §288: - SQL-Schema (llx_mahnung_mahnung, llx_mahnung_stufe) - CRUD-Klassen (Mahnung, MahnungStufe, MahnungVorschlag) - TCPDF DIN-5008 PDF-Generierung - Verzugszinsberechnung B2C/B2B + §288 Abs.5 Pauschale - Trigger: offene Mahnungen bei Zahlungseingang schliessen - Hook: Tab + Button auf Rechnungs-/Kundenkarte - Cron: taegl. Vorschlagsliste + Ntfy-Push - Deploy-Pipeline (.forgejo/workflows/deploy.yml) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
7.1 KiB
PHP
232 lines
7.1 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: ueberfaellige Rechnungen einsammeln und je Rechnung
|
|
* die naechste 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 ueberfaelliger Rechnung einen Vorschlag (oder ueberspringt sie,
|
|
* wenn alle Stufen bereits durchlaufen sind oder die Wartefrist noch laeuft).
|
|
*
|
|
* Rueckgabe-Schluessel 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'
|
|
* @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.statut,";
|
|
$sql .= " s.nom AS soc_nom, s.tva_intra";
|
|
$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.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;
|
|
}
|
|
$result[] = $row;
|
|
}
|
|
$this->db->free($resql);
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Berechnet fuer eine einzelne Rechnung, ob/wozu eine Mahnung vorgeschlagen wird.
|
|
*
|
|
* @param object $factureObj DB-Reihe aus facture+societe
|
|
* @param int $today Unix-Zeit
|
|
* @return array|null
|
|
*/
|
|
private function buildVorschlag($factureObj, $today)
|
|
{
|
|
$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);
|
|
|
|
// Naechste Stufe ermitteln
|
|
$proposedStufe = null;
|
|
if ($lastMahnung === null) {
|
|
// Noch nichts gemahnt -> Stufe 1, sobald frist_tage erreicht
|
|
if (isset($this->stufen[1]) && $tageVerzug >= (int) $this->stufen[1]->frist_tage) {
|
|
$proposedStufe = 1;
|
|
}
|
|
} else {
|
|
// Bereits gemahnt -> naechste Stufe wenn Wartefrist seit letzter Mahnung abgelaufen
|
|
$lastStufe = (int) $lastMahnung->stufe;
|
|
$nextStufe = $lastStufe + 1;
|
|
if ($lastStufe >= 3 || !isset($this->stufen[$nextStufe])) {
|
|
return null; // alle Stufen ausgeschoepft
|
|
}
|
|
// Wartefrist: neue_frist_tage der zuletzt gemahnten Stufe
|
|
$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;
|
|
}
|
|
}
|
|
|
|
if ($proposedStufe === null) {
|
|
return null;
|
|
}
|
|
|
|
// Offenen Betrag berechnen (total_ttc - Summe aller Zahlungen)
|
|
$betragOffen = $this->getBetragOffen((int) $factureObj->facture_id, (float) $factureObj->total_ttc);
|
|
if ($betragOffen <= 0) {
|
|
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,
|
|
'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' => $this->stufen[$proposedStufe]->label,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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) zurueck oder null.
|
|
*
|
|
* @param int $stufe
|
|
* @return MahnungStufe|null
|
|
*/
|
|
public function getStufe($stufe)
|
|
{
|
|
$this->loadStufen();
|
|
return $this->stufen[(int) $stufe] ?? null;
|
|
}
|
|
}
|