mahnung/class/mahnungvorschlag.class.php
Eduard Wisch d1db85322b
All checks were successful
Deploy mahnung / deploy (push) Successful in 14s
Initiales Release: Mahnung-Modul v0.1.0 [deploy]
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>
2026-05-07 12:09:37 +02:00

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