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>
360 lines
12 KiB
PHP
360 lines
12 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/mahnungpdf.class.php
|
|
* \ingroup mahnung
|
|
* \brief PDF-Generator fuer Mahnschreiben (DIN-5008 Form A).
|
|
*
|
|
* Nutzt Dolibarrs TCPDF-Wrapper (pdf_getInstance) und schreibt das fertige
|
|
* PDF in den Dokumenten-Ordner der Original-Rechnung
|
|
* documents/facture/{ref-rechnung}/mahnung-{stufe}-{ref-mahnung}.pdf
|
|
* Damit erscheint die Mahnung automatisch im Dokumente-Tab der Rechnung.
|
|
*/
|
|
|
|
require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php';
|
|
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
|
|
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
|
|
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
|
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
|
|
|
|
class MahnungPdf
|
|
{
|
|
/** @var DoliDB */
|
|
public $db;
|
|
|
|
/** @var string */
|
|
public $error = '';
|
|
|
|
/**
|
|
* @param DoliDB $db
|
|
*/
|
|
public function __construct($db)
|
|
{
|
|
$this->db = $db;
|
|
}
|
|
|
|
/**
|
|
* Erzeugt das PDF zum Mahnvorgang. Setzt $mahnung->pdf_path nach Erfolg
|
|
* und schreibt sie via update($user) in die DB.
|
|
*
|
|
* @param Mahnung $mahnung
|
|
* @param User $user
|
|
* @return string|false Absoluter Pfad zur PDF-Datei oder false bei Fehler
|
|
*/
|
|
public function generate(Mahnung $mahnung, $user)
|
|
{
|
|
global $conf, $langs, $mysoc;
|
|
|
|
$langs->loadLangs(array('main', 'bills', 'companies', 'mahnung@mahnung'));
|
|
|
|
// Original-Rechnung + Kunde laden
|
|
$facture = new Facture($this->db);
|
|
if ($facture->fetch((int) $mahnung->fk_facture) <= 0) {
|
|
$this->error = 'Rechnung '.((int) $mahnung->fk_facture).' nicht ladbar.';
|
|
return false;
|
|
}
|
|
|
|
$societe = new Societe($this->db);
|
|
if ($societe->fetch((int) $mahnung->fk_soc) <= 0) {
|
|
$this->error = 'Kunde '.((int) $mahnung->fk_soc).' nicht ladbar.';
|
|
return false;
|
|
}
|
|
|
|
$stufeObj = new MahnungStufe($this->db);
|
|
if ($stufeObj->fetchByStufe((int) $mahnung->stufe) <= 0) {
|
|
$this->error = 'Mahnstufe '.((int) $mahnung->stufe).' nicht konfiguriert.';
|
|
return false;
|
|
}
|
|
|
|
// Ziel-Verzeichnis im Doc-Ordner der Rechnung
|
|
$dirOutput = $this->getOutputDir($facture);
|
|
if (!dol_mkdir($dirOutput)) {
|
|
$this->error = 'Kann Verzeichnis nicht anlegen: '.$dirOutput;
|
|
return false;
|
|
}
|
|
$filename = 'mahnung-'.((int) $mahnung->stufe).'-'.dol_sanitizeFileName($mahnung->ref).'.pdf';
|
|
$absPath = $dirOutput.'/'.$filename;
|
|
|
|
// PDF-Instanz
|
|
$pdf = pdf_getInstance(array('210', '297'));
|
|
$default_font_size = pdf_getPDFFontSize($langs);
|
|
$pdf->SetAutoPageBreak(true, 25);
|
|
$pdf->SetFont(pdf_getPDFFont($langs), '', $default_font_size);
|
|
|
|
$pdf->SetTitle($langs->trans('MahnungStufe').' '.((int) $mahnung->stufe).' — '.$facture->ref);
|
|
$pdf->SetSubject($langs->trans('MahnungRef').' '.$mahnung->ref);
|
|
$pdf->SetAuthor((string) $mysoc->name);
|
|
$pdf->SetCreator('Dolibarr Mahnung-Modul');
|
|
|
|
$pdf->Open();
|
|
$pdf->AddPage();
|
|
|
|
$this->renderHeader($pdf, $facture, $societe, $mahnung, $stufeObj);
|
|
$this->renderBody($pdf, $facture, $societe, $mahnung, $stufeObj);
|
|
$this->renderFooter($pdf);
|
|
|
|
$pdf->Output($absPath, 'F');
|
|
|
|
// Pfad in DB persistieren
|
|
$mahnung->pdf_path = $absPath;
|
|
$mahnung->update($user);
|
|
|
|
return $absPath;
|
|
}
|
|
|
|
/**
|
|
* Adressfenster, Datum, Betreff (DIN-5008 Form A: Adressfeld 45mm hoch ab 27mm).
|
|
*
|
|
* @param TCPDF $pdf
|
|
* @param Facture $facture
|
|
* @param Societe $societe
|
|
* @param Mahnung $mahnung
|
|
* @param MahnungStufe $stufe
|
|
* @return void
|
|
*/
|
|
private function renderHeader($pdf, $facture, $societe, $mahnung, $stufe)
|
|
{
|
|
global $langs, $mysoc;
|
|
|
|
// Absender klein im Adressfeld (Faltmarke darueber, oben in DIN 5008 Adresszeile)
|
|
$pdf->SetFont('helvetica', '', 7);
|
|
$pdf->SetXY(25, 50);
|
|
$senderLine = trim(($mysoc->name ?? '').' · '.($mysoc->address ?? '').' · '.($mysoc->zip ?? '').' '.($mysoc->town ?? ''));
|
|
$pdf->Cell(85, 4, $senderLine, 0, 1, 'L');
|
|
|
|
// Empfaenger-Block (Adressfenster: links 25mm, ab y=55)
|
|
$pdf->SetFont('helvetica', '', 11);
|
|
$pdf->SetXY(25, 55);
|
|
$lines = array();
|
|
if (!empty($societe->name)) {
|
|
$lines[] = $societe->name;
|
|
}
|
|
if (!empty($societe->name_alias)) {
|
|
$lines[] = $societe->name_alias;
|
|
}
|
|
if (!empty($societe->address)) {
|
|
$lines[] = $societe->address;
|
|
}
|
|
$ortzeile = trim(($societe->zip ?? '').' '.($societe->town ?? ''));
|
|
if (!empty($ortzeile)) {
|
|
$lines[] = $ortzeile;
|
|
}
|
|
foreach ($lines as $line) {
|
|
$pdf->Cell(85, 5, $line, 0, 1, 'L');
|
|
$pdf->SetX(25);
|
|
}
|
|
|
|
// Bezugszeichen-Zeile rechts (DIN-5008): Datum + Mahn-Nr.
|
|
$pdf->SetFont('helvetica', '', 9);
|
|
$pdf->SetXY(125, 50);
|
|
$pdf->Cell(60, 4, $langs->trans('Date').': '.dol_print_date($mahnung->date_mahnung, 'day'), 0, 1, 'L');
|
|
$pdf->SetX(125);
|
|
$pdf->Cell(60, 4, $langs->trans('MahnungRef').': '.$mahnung->ref, 0, 1, 'L');
|
|
$pdf->SetX(125);
|
|
$pdf->Cell(60, 4, $langs->trans('MahnungRechnung').': '.$facture->ref, 0, 1, 'L');
|
|
|
|
// Betreff
|
|
$pdf->SetXY(25, 100);
|
|
$pdf->SetFont('helvetica', 'B', 12);
|
|
$betreff = $stufe->label.' — '.$langs->trans('MahnungRechnung').' '.$facture->ref;
|
|
$pdf->Cell(0, 6, $betreff, 0, 1, 'L');
|
|
}
|
|
|
|
/**
|
|
* Anrede, Intro, Tabelle (Rechnung/Datum/Betrag/gezahlt/offen),
|
|
* Gebuehrenblock, Gesamtsumme, neue Frist, Bankverbindung.
|
|
*
|
|
* @param TCPDF $pdf
|
|
* @param Facture $facture
|
|
* @param Societe $societe
|
|
* @param Mahnung $mahnung
|
|
* @param MahnungStufe $stufe
|
|
* @return void
|
|
*/
|
|
private function renderBody($pdf, $facture, $societe, $mahnung, $stufe)
|
|
{
|
|
global $langs, $mysoc;
|
|
|
|
$pdf->SetFont('helvetica', '', 11);
|
|
$pdf->SetXY(25, 110);
|
|
|
|
// Anrede
|
|
$anrede = 'Sehr geehrte Damen und Herren,';
|
|
$pdf->Cell(0, 5, $anrede, 0, 1, 'L');
|
|
$pdf->SetX(25);
|
|
$pdf->Ln(2);
|
|
|
|
// Intro aus Stufen-Konfig (Fallback Default-Text je Stufe)
|
|
$intro = (string) $stufe->pdf_intro;
|
|
if (empty($intro)) {
|
|
$intro = $this->defaultIntro((int) $mahnung->stufe);
|
|
}
|
|
$pdf->SetX(25);
|
|
$pdf->MultiCell(160, 5, $intro, 0, 'L');
|
|
$pdf->Ln(3);
|
|
|
|
// Rechnungs-Tabelle
|
|
$pdf->SetFont('helvetica', 'B', 10);
|
|
$pdf->SetX(25);
|
|
$pdf->Cell(40, 6, $langs->trans('MahnungRechnung'), 'B', 0, 'L');
|
|
$pdf->Cell(30, 6, $langs->trans('Date'), 'B', 0, 'L');
|
|
$pdf->Cell(30, 6, $langs->trans('TotalTTC'), 'B', 0, 'R');
|
|
$pdf->Cell(30, 6, $langs->trans('AlreadyPaid'), 'B', 0, 'R');
|
|
$pdf->Cell(30, 6, $langs->trans('MahnungBetragOffen'), 'B', 1, 'R');
|
|
|
|
$pdf->SetFont('helvetica', '', 10);
|
|
$pdf->SetX(25);
|
|
$gezahlt = (float) $facture->total_ttc - (float) $mahnung->betrag_offen;
|
|
$pdf->Cell(40, 6, $facture->ref, 0, 0, 'L');
|
|
$pdf->Cell(30, 6, dol_print_date($facture->date, 'day'), 0, 0, 'L');
|
|
$pdf->Cell(30, 6, price((float) $facture->total_ttc).' EUR', 0, 0, 'R');
|
|
$pdf->Cell(30, 6, price($gezahlt).' EUR', 0, 0, 'R');
|
|
$pdf->Cell(30, 6, price((float) $mahnung->betrag_offen).' EUR', 0, 1, 'R');
|
|
$pdf->Ln(3);
|
|
|
|
// Gebuehrenblock
|
|
$pdf->SetX(25);
|
|
$pdf->SetFont('helvetica', '', 10);
|
|
$pdf->Cell(130, 6, $langs->trans('MahnungBetragOffen'), 0, 0, 'L');
|
|
$pdf->Cell(30, 6, price((float) $mahnung->betrag_offen).' EUR', 0, 1, 'R');
|
|
if ((float) $mahnung->mahngebuehr > 0) {
|
|
$pdf->SetX(25);
|
|
$pdf->Cell(130, 6, $langs->trans('MahnungGebuehr'), 0, 0, 'L');
|
|
$pdf->Cell(30, 6, price((float) $mahnung->mahngebuehr).' EUR', 0, 1, 'R');
|
|
}
|
|
if ((float) $mahnung->pauschale_b2b > 0) {
|
|
$pdf->SetX(25);
|
|
$pdf->Cell(130, 6, $langs->trans('MahnungPauschaleB2B').' (BGB §288 Abs. 5)', 0, 0, 'L');
|
|
$pdf->Cell(30, 6, price((float) $mahnung->pauschale_b2b).' EUR', 0, 1, 'R');
|
|
}
|
|
if ((float) $mahnung->verzugszinsen > 0) {
|
|
$pdf->SetX(25);
|
|
$basis = $mahnung->basiszins_snapshot !== null ? (float) $mahnung->basiszins_snapshot : 0.0;
|
|
$auf = $mahnung->customertype === Mahnung::KUNDENTYP_B2B
|
|
? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0')
|
|
: (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0');
|
|
$satz = $basis + $auf;
|
|
$pdf->Cell(130, 6, $langs->trans('MahnungVerzugszinsen').' ('.number_format($satz, 2, ',', '.').' %)', 0, 0, 'L');
|
|
$pdf->Cell(30, 6, price((float) $mahnung->verzugszinsen).' EUR', 0, 1, 'R');
|
|
}
|
|
|
|
// Gesamtsumme
|
|
$pdf->Ln(1);
|
|
$pdf->SetX(25);
|
|
$pdf->SetFont('helvetica', 'B', 11);
|
|
$pdf->Cell(130, 7, $langs->trans('MahnungSumme'), 'T', 0, 'L');
|
|
$pdf->Cell(30, 7, price((float) $mahnung->summe_mahnung).' EUR', 'T', 1, 'R');
|
|
|
|
$pdf->Ln(5);
|
|
|
|
// Neue Frist
|
|
$pdf->SetX(25);
|
|
$pdf->SetFont('helvetica', '', 11);
|
|
$frist = $mahnung->date_lim_reglement_neu ? dol_print_date($mahnung->date_lim_reglement_neu, 'day') : '';
|
|
$fristText = empty($frist)
|
|
? 'Wir bitten Sie um umgehende Begleichung.'
|
|
: 'Wir bitten Sie, den ausstehenden Betrag bis spaetestens '.$frist.' auf das unten genannte Konto zu ueberweisen.';
|
|
$pdf->MultiCell(160, 5, $fristText, 0, 'L');
|
|
|
|
$pdf->Ln(4);
|
|
$pdf->SetX(25);
|
|
$pdf->Cell(0, 5, 'Mit freundlichen Gruessen', 0, 1, 'L');
|
|
$pdf->SetX(25);
|
|
$pdf->Cell(0, 5, (string) $mysoc->name, 0, 1, 'L');
|
|
}
|
|
|
|
/**
|
|
* Fusszeile mit Bankverbindung + Firmen-Footer.
|
|
*
|
|
* @param TCPDF $pdf
|
|
* @return void
|
|
*/
|
|
private function renderFooter($pdf)
|
|
{
|
|
global $mysoc;
|
|
|
|
$pdf->SetY(-30);
|
|
$pdf->SetFont('helvetica', 'I', 8);
|
|
$lines = array();
|
|
$lines[] = trim(($mysoc->name ?? '').' · '.($mysoc->address ?? '').' · '.($mysoc->zip ?? '').' '.($mysoc->town ?? ''));
|
|
if (!empty($mysoc->email)) {
|
|
$lines[] = 'E-Mail: '.$mysoc->email;
|
|
}
|
|
if (!empty($mysoc->phone)) {
|
|
$lines[] = 'Tel: '.$mysoc->phone;
|
|
}
|
|
// Bankverbindung aus Standard-Bankaccount
|
|
$bankAccount = $this->getDefaultBankLine();
|
|
if (!empty($bankAccount)) {
|
|
$lines[] = $bankAccount;
|
|
}
|
|
foreach ($lines as $l) {
|
|
$pdf->Cell(0, 4, $l, 0, 1, 'C');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Standard-Bankkonto in einer Zeile (Bank · IBAN · BIC).
|
|
*
|
|
* @return string
|
|
*/
|
|
private function getDefaultBankLine()
|
|
{
|
|
$sql = "SELECT label, iban_prefix as iban, bic FROM ".MAIN_DB_PREFIX."bank_account";
|
|
$sql .= " WHERE clos = 0 AND default_rib = 1";
|
|
$sql .= " LIMIT 1";
|
|
$resql = $this->db->query($sql);
|
|
if (!$resql || !$this->db->num_rows($resql)) {
|
|
return '';
|
|
}
|
|
$obj = $this->db->fetch_object($resql);
|
|
$this->db->free($resql);
|
|
$parts = array_filter(array($obj->label, $obj->iban ? 'IBAN '.$obj->iban : '', $obj->bic ? 'BIC '.$obj->bic : ''));
|
|
return implode(' · ', $parts);
|
|
}
|
|
|
|
/**
|
|
* Default-Intro je Stufe (wenn Setup leer ist).
|
|
*
|
|
* @param int $stufe
|
|
* @return string
|
|
*/
|
|
private function defaultIntro($stufe)
|
|
{
|
|
switch ((int) $stufe) {
|
|
case 1:
|
|
return 'unsere unten aufgefuehrte Rechnung ist trotz Ablauf der Zahlungsfrist noch nicht beglichen. '
|
|
. 'Vielleicht ist Ihnen dies entgangen — wir bitten Sie hoeflich, den ausstehenden Betrag zeitnah '
|
|
. 'zu ueberweisen.';
|
|
case 2:
|
|
return 'leider mussten wir feststellen, dass die unten aufgefuehrte Rechnung trotz unserer '
|
|
. 'Zahlungserinnerung weiterhin offen ist. Wir bitten Sie nun nachdruecklich um Begleichung '
|
|
. 'des offenen Betrags zuzueglich Verzugszinsen und Mahnkosten.';
|
|
case 3:
|
|
default:
|
|
return 'wir haben Sie bereits zweimal an die Begleichung der unten aufgefuehrten Rechnung erinnert. '
|
|
. 'Sollte der offene Betrag inkl. Verzugszinsen und Mahnkosten nicht innerhalb der angegebenen Frist '
|
|
. 'auf unserem Konto eingehen, sehen wir uns gezwungen, weitere rechtliche Schritte einzuleiten.';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ziel-Verzeichnis: documents/facture/{ref}/
|
|
*
|
|
* @param Facture $facture
|
|
* @return string
|
|
*/
|
|
private function getOutputDir($facture)
|
|
{
|
|
global $conf;
|
|
$documentDir = !empty($conf->facture->multidir_output[$facture->entity])
|
|
? $conf->facture->multidir_output[$facture->entity]
|
|
: $conf->facture->dir_output;
|
|
return rtrim($documentDir, '/').'/'.dol_sanitizeFileName($facture->ref);
|
|
}
|
|
}
|