mahnung/ajax/sammelbrief.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

230 lines
6.7 KiB
PHP

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* GPL v3 (siehe COPYING).
*/
/**
* \file htdocs/custom/mahnung/ajax/sammelbrief.php
* \ingroup mahnung
* \brief AJAX/Form-Endpoint: Sammelbrief — fuer eine Auswahl von Rechnungen
* Mahnungen erzeugen und alle Einzel-PDFs in EIN PDF zusammenfassen.
*
* POST:
* facture_ids[] Rechnungs-IDs
* stufe (opt) Stufe erzwingen (sonst Vorschlag)
* token CSRF
*
* Response: PDF-Download "sammelbrief-YYYYMMDD-N.pdf".
*/
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
ob_start();
require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungpdf.class.php';
global $db, $user, $langs;
$langs->loadLangs(array('mahnung@mahnung'));
// CSRF
$postedToken = GETPOST('token', 'alphanohtml');
if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) {
while (ob_get_level() > 0) {
ob_end_clean();
}
httpExitError(403, 'Token-Verifikation fehlgeschlagen.');
}
// Permission
if (!$user->hasRight('mahnung', 'send') && !$user->hasRight('mahnung', 'write')) {
while (ob_get_level() > 0) {
ob_end_clean();
}
httpExitError(403, 'Nicht berechtigt.');
}
$factureIds = GETPOST('facture_ids', 'array:int');
$factureIds = array_values(array_filter(array_unique(array_map('intval', $factureIds)), function ($v) {
return $v > 0;
}));
if (empty($factureIds)) {
while (ob_get_level() > 0) {
ob_end_clean();
}
httpExitError(400, 'Keine Rechnungen ausgewaehlt.');
}
$forceStufe = GETPOSTINT('stufe');
$forceStufe = ($forceStufe >= 1 && $forceStufe <= 3) ? $forceStufe : 0;
$service = new MahnungVorschlag($db);
$pdfGen = new MahnungPdf($db);
$basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27');
$paths = array();
foreach ($factureIds as $fid) {
$rows = $service->getVorschlaege();
$row = null;
foreach ($rows as $r) {
if ((int) $r['facture_id'] === (int) $fid) {
$row = $r;
break;
}
}
if ($row === null) {
continue;
}
$stufeNr = $forceStufe ?: (int) $row['vorgeschlagene_stufe'];
$stufe = $service->getStufe($stufeNr);
if ($stufe === null) {
continue;
}
$mahnung = new Mahnung($db);
$mahnung->fk_facture = $fid;
$mahnung->fk_soc = (int) $row['soc_id'];
$mahnung->stufe = $stufeNr;
$mahnung->date_mahnung = dol_now();
$mahnung->date_lim_reglement_alt = $row['facture_date_lim_reglement'];
$mahnung->date_lim_reglement_neu = dol_time_plus_duree(dol_now(), (int) $stufe->neue_frist_tage, 'd');
$mahnung->betrag_offen = (float) $row['betrag_offen'];
$mahnung->customertype = $row['kundentyp'];
$mahnung->basiszins_snapshot = $basiszins;
$mahnung->versandart = Mahnung::VERSAND_DRUCK;
$mahnung->mahngebuehr = $stufe->getMahngebuehr($mahnung->customertype);
if ($mahnung->customertype === Mahnung::KUNDENTYP_B2B && (int) $stufe->pauschale_b2b_einmalig === 1
&& !pauschaleBereitsAngewendet($db, $fid)) {
$mahnung->pauschale_b2b = (float) getDolGlobalString('MAHNUNG_PAUSCHALE_B2B', '40.00');
}
$mahnung->verzugszinsen = Mahnung::berechneVerzugszinsen(
$mahnung->betrag_offen,
(int) $row['tage_verzug'],
$mahnung->customertype,
$basiszins,
$stufe->getZinssatzOverride($mahnung->customertype)
);
$mahnung->rechneSumme();
$mahnung->status = Mahnung::STATUS_ERSTELLT;
if ($mahnung->create($user) <= 0) {
continue;
}
$pdfPath = $pdfGen->generate($mahnung, $user);
if ($pdfPath !== false) {
$paths[] = $pdfPath;
}
}
if (empty($paths)) {
while (ob_get_level() > 0) {
ob_end_clean();
}
httpExitError(500, 'Keine PDFs erzeugt — pruefe ob die Rechnungen mahnreif sind.');
}
// Wenn TCPDI verfuegbar, Seiten aller PDFs in EIN Dokument importieren.
// Andernfalls ZIP-Fallback wuerde sich anbieten — wir liefern stattdessen
// eine PDF-Konkatenation via TCPDI (Bestandteil von tecnickcom/tc-lib-pdf
// und Dolibarr-Tcpdi-Wrapper).
$absOut = this_buildSammelbriefPdf($paths);
if ($absOut === null || !file_exists($absOut)) {
while (ob_get_level() > 0) {
ob_end_clean();
}
httpExitError(500, 'Sammelbrief-PDF konnte nicht erzeugt werden.');
}
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="sammelbrief-'.dol_print_date(dol_now(), 'dayxcard').'.pdf"');
header('Content-Length: '.filesize($absOut));
readfile($absOut);
@unlink($absOut);
exit;
// ----------------------------------------------------------------
/**
* Konkateniert mehrere PDF-Dateien zu einer Datei. Gibt absoluten Pfad zurueck
* oder null bei Fehler.
*
* @param string[] $paths
* @return string|null
*/
function this_buildSammelbriefPdf(array $paths)
{
if (!class_exists('TCPDI')) {
// Dolibarr liefert TCPDI ueber tcpdf/tcpdi.php aus
$tcpdiPath = DOL_DOCUMENT_ROOT.'/includes/tcpdf/tcpdi.php';
if (file_exists($tcpdiPath)) {
require_once $tcpdiPath;
}
}
if (!class_exists('TCPDI')) {
dol_syslog('Mahnung Sammelbrief: TCPDI-Klasse nicht verfuegbar — nur erstes PDF wird zurueckgeliefert', LOG_WARNING);
return $paths[0] ?? null;
}
$out = sys_get_temp_dir().'/mahnung-sammelbrief-'.uniqid('', true).'.pdf';
$pdf = new TCPDI();
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
foreach ($paths as $src) {
if (!file_exists($src)) {
continue;
}
$pageCount = $pdf->setSourceFile($src);
for ($p = 1; $p <= $pageCount; $p++) {
$tplIdx = $pdf->importPage($p);
$size = $pdf->getTemplateSize($tplIdx);
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
$pdf->useTemplate($tplIdx);
}
}
$pdf->Output($out, 'F');
return $out;
}
/**
* Prueft, ob fuer eine Rechnung bereits §288-B2B-Pauschale gesetzt wurde.
*
* @param DoliDB $db
* @param int $factureId
* @return bool
*/
function pauschaleBereitsAngewendet($db, $factureId)
{
$sql = "SELECT 1 FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
$sql .= " WHERE fk_facture = ".((int) $factureId);
$sql .= " AND status NOT IN (".Mahnung::STATUS_STORNIERT.")";
$sql .= " AND pauschale_b2b > 0";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if (!$resql) {
return false;
}
$has = (bool) $db->num_rows($resql);
$db->free($resql);
return $has;
}
/**
* @param int $code
* @param string $message
*/
function httpExitError($code, $message)
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}