mahnung/class/mahnungpdf.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

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