bericht/ajax/generate_pdf.php
Eduard Wisch 923b50d65a
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
feat: Initiales Release Bericht-Modul v1.0.0 [deploy]
Dolibarr-Modul für Arbeitsberichte aus Rechnungs-Anhängen mit Browser-PDF-Editor.

- Reiter "Bericht" auf Rechnungen, Aufträgen und Angeboten
- Anhänge-Browser inkl. verknüpfter Objekte (Auftrag → Rechnung)
- PDF.js + Fabric.js Browser-Editor: Pfeile, Kreise, Rechtecke, Freihand, Text
- SortableJS Seiten-Verwaltung mit Drag&Drop
- ODT-Deckblatt mit Platzhaltern, Templates im Admin verwaltbar
- TCPDF + FPDI Finalisierung mit eingebrannten Annotationen
- ECM-Verknüpfung: PDF erscheint unter Verknüpfte Dokumente
- Auftragsnummer aus existierendem Extrafield options_auftragsnummer
- Mehrere Berichte pro Dokument
- Beim Aktivieren werden vorhandene Extrafields nicht überschrieben

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:18:59 +02:00

287 lines
11 KiB
PHP

<?php
/* Finalisiert einen Bericht: Deckblatt aus ODT rendern, Seiten + Annotationen mergen,
* finales PDF unter dem Parent-Ordner ablegen und in llx_ecm_files registrieren.
*
* POST: berichtid, token
*/
require_once __DIR__.'/_inc.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
global $db, $user, $conf;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$berichtid = (int) ($_POST['berichtid'] ?? 0);
$bericht = new Bericht($db);
if ($bericht->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
$parent = bericht_fetch_parent($db, $bericht->element_type, $bericht->fk_element);
if (!$parent) bericht_ajax_fail('Parent nicht gefunden', 404);
$pages = BerichtPage::fetchAllForBericht($db, $bericht->id);
if (empty($pages)) bericht_ajax_fail('Bericht enthält keine Seiten');
// TCPDF + FPDI laden
$tcpdf_loaded = false;
foreach (array(
DOL_DOCUMENT_ROOT.'/includes/tecnickcom/tcpdf/tcpdf.php',
DOL_DOCUMENT_ROOT.'/includes/tcpdf/tcpdf.php',
) as $p) {
if (file_exists($p)) { require_once $p; $tcpdf_loaded = true; break; }
}
if (!$tcpdf_loaded) bericht_ajax_fail('TCPDF nicht gefunden');
$fpdi_loaded = false;
foreach (array(
DOL_DOCUMENT_ROOT.'/includes/setasign/vendor/setasign/fpdi/src/Tcpdf/Fpdi.php',
DOL_DOCUMENT_ROOT.'/includes/fpdi/src/Tcpdf/Fpdi.php',
) as $p) {
if (file_exists($p)) { require_once $p; $fpdi_loaded = true; break; }
}
// FPDI ist optional — wenn fehlt, können wir keine bestehenden PDFs einbetten,
// aber Bilder + Annotationen funktionieren weiterhin.
if ($fpdi_loaded) {
$pdf = new \setasign\Fpdi\Tcpdf\Fpdi('P', 'mm', 'A4', true, 'UTF-8', false);
} else {
$pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false);
}
$pdf->SetCreator('Dolibarr Bericht-Modul');
$pdf->SetAuthor($user->getFullName($langs));
$pdf->SetTitle($bericht->titel ?: $bericht->ref);
$pdf->SetMargins(10, 10, 10);
$pdf->SetAutoPageBreak(true, 10);
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
// --- Deckblatt aus ODT-Template ---
$tempdir = DOL_DATA_ROOT.'/bericht/temp/'.$berichtid;
if (!is_dir($tempdir)) dol_mkdir($tempdir);
if (!empty($bericht->template_odt)) {
$template_path = DOL_DATA_ROOT.'/bericht/templates/'.dol_sanitizeFileName($bericht->template_odt);
if (file_exists($template_path)) {
$cover_pdf = bericht_render_cover($template_path, $bericht, $parent, $tempdir);
if ($cover_pdf && file_exists($cover_pdf) && $fpdi_loaded) {
$cover_pages = $pdf->setSourceFile($cover_pdf);
for ($cp = 1; $cp <= $cover_pages; $cp++) {
$tpl = $pdf->importPage($cp);
$size = $pdf->getTemplateSize($tpl);
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
$pdf->useTemplate($tpl);
}
}
}
}
// --- Seiten ---
foreach ($pages as $page) {
$full = bericht_resolve_data_path($page->source_path);
if (!$full || !file_exists($full)) continue;
if ($page->source_type === 'image' || preg_match('/\.(png|jpe?g)$/i', $full)) {
// Bild als A4-Seite
$pdf->AddPage('P', 'A4');
list($iw, $ih) = @getimagesize($full);
if ($iw && $ih) {
$maxW = 190; $maxH = 277;
$ratio = min($maxW / $iw, $maxH / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
$x = (210 - $w) / 2; $y = 10;
$pdf->Image($full, $x, $y, $w, $h);
// Annotationen drauf
bericht_burn_annotations($pdf, $page->fabric_json, $x, $y, $w, $h);
}
} elseif ($fpdi_loaded && preg_match('/\.pdf$/i', $full)) {
try {
$pdf->setSourceFile($full);
$tpl = $pdf->importPage($page->source_page ?: 1);
$size = $pdf->getTemplateSize($tpl);
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
$pdf->useTemplate($tpl);
// Annotationen über volle Seitenfläche
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $size['width'], $size['height']);
} catch (Throwable $e) {
// Seite überspringen
}
}
// Notiz unten
if (!empty($page->note)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
}
// --- Speichern ---
$dir_key = bericht_element_to_dir_key($bericht->element_type);
$target_dir = $conf->{$dir_key}->multidir_output[$parent->entity].'/'.dol_sanitizeFileName($parent->ref);
if (!is_dir($target_dir)) dol_mkdir($target_dir);
$filename = 'Bericht_'.dol_sanitizeFileName($bericht->auftragsnummer ?: $bericht->ref).'_'.dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'.pdf';
$target_path = $target_dir.'/'.$filename;
$pdf->Output($target_path, 'F');
if (!file_exists($target_path)) bericht_ajax_fail('PDF wurde nicht erzeugt');
// In ECM registrieren (taucht unter Verknüpfte Dokumente auf)
require_once DOL_DOCUMENT_ROOT.'/ecm/class/ecmfiles.class.php';
$ecmfile = new EcmFiles($db);
$ecmfile->filepath = $dir_key.'/'.dol_sanitizeFileName($parent->ref);
$ecmfile->filename = $filename;
$ecmfile->fullpath_orig = $target_path;
$ecmfile->src_object_type = $dir_key;
$ecmfile->src_object_id = $parent->id;
$ecmfile->label = md5_file($target_path);
@$ecmfile->create($user); // Fehler bei bereits existierendem Eintrag ignorieren
// Bericht-Status auf Final
$bericht->status = Bericht::STATUS_FINAL;
$bericht->final_pdf_path = str_replace(DOL_DATA_ROOT.'/', '', $target_path);
$bericht->update($user);
bericht_ajax_ok(array(
'filename' => $filename,
'path' => $bericht->final_pdf_path,
));
/**
* Rendert das ODT-Deckblatt mit Platzhaltern und konvertiert es zu PDF.
* Nutzt LibreOffice headless.
*
* @return string|null Pfad zum erzeugten PDF oder null bei Fehler
*/
function bericht_render_cover($template_path, $bericht, $parent, $tempdir)
{
global $conf, $user, $langs;
$odt_loader = DOL_DOCUMENT_ROOT.'/includes/odtphp/odf.php';
if (!file_exists($odt_loader)) return null;
require_once $odt_loader;
try {
$odf = new Odf($template_path, array('PATH_TO_TMP' => $tempdir));
$vars = array(
'auftragsnummer' => $bericht->auftragsnummer ?: '',
'angebotsnummer' => $parent->array_options['options_angebotsnummer'] ?? '',
'rechnungsnummer' => $parent->ref ?? '',
'kunde_name' => $parent->thirdparty->name ?? '',
'kunde_adresse' => trim(($parent->thirdparty->address ?? '')."\n".($parent->thirdparty->zip ?? '').' '.($parent->thirdparty->town ?? '')),
'datum' => dol_print_date(dol_now(), 'day'),
'beschreibung' => $parent->array_options['options_beschreibung'] ?? '',
'hinweis' => $parent->array_options['options_hinweis'] ?? '',
'bericht_titel' => $bericht->titel ?? '',
'ersteller' => $user->getFullName($langs ?? null) ?: $user->login,
);
foreach ($vars as $k => $v) {
try { $odf->setVars($k, $v, true, 'UTF-8'); } catch (Throwable $e) {}
}
$odt_out = $tempdir.'/cover_'.$bericht->id.'.odt';
$odf->saveToDisk($odt_out);
// ODT → PDF via LibreOffice
$lobin = getDolGlobalString('BERICHT_LIBREOFFICE_BIN', '/usr/bin/libreoffice');
$cmd = escapeshellcmd($lobin)
.' --headless --convert-to pdf --outdir '.escapeshellarg($tempdir).' '.escapeshellarg($odt_out).' 2>&1';
@shell_exec($cmd);
$pdf_out = preg_replace('/\.odt$/i', '.pdf', $odt_out);
return file_exists($pdf_out) ? $pdf_out : null;
} catch (Throwable $e) {
return null;
}
}
/**
* Rendert Fabric.js-Annotationen ins TCPDF-Objekt.
* Versteht die wichtigsten Shape-Typen: rect, circle, line, path (freihand), text.
* Koordinaten in fabric_json sind in Pixel relativ zum Bild → werden auf mm skaliert.
*/
function bericht_burn_annotations(TCPDF $pdf, $fabric_json, $x, $y, $w, $h)
{
if (empty($fabric_json)) return;
$data = json_decode($fabric_json, true);
if (!is_array($data) || empty($data['objects'])) return;
// Skalierung: Fabric arbeitet in Pixel, TCPDF in mm
// Wir nehmen an, der Fabric-Canvas hatte die gleiche Pixelgröße wie das gerenderte Bild,
// und das Bild belegt im PDF (x,y,w,h).
$cw = $data['width'] ?? null;
$ch = $data['height'] ?? null;
if (!$cw || !$ch) return;
$sx = $w / $cw;
$sy = $h / $ch;
foreach ($data['objects'] as $obj) {
$stroke = isset($obj['stroke']) ? bericht_hex_to_rgb($obj['stroke']) : array(255, 0, 0);
$sw = ($obj['strokeWidth'] ?? 2) * $sx;
$pdf->SetDrawColor($stroke[0], $stroke[1], $stroke[2]);
$pdf->SetLineWidth(max(0.2, $sw));
$type = $obj['type'] ?? '';
$ox = ($obj['left'] ?? 0) * $sx + $x;
$oy = ($obj['top'] ?? 0) * $sy + $y;
$ow = ($obj['width'] ?? 0) * ($obj['scaleX'] ?? 1) * $sx;
$oh = ($obj['height'] ?? 0) * ($obj['scaleY'] ?? 1) * $sy;
switch ($type) {
case 'rect':
$pdf->Rect($ox, $oy, $ow, $oh, 'D');
break;
case 'circle':
case 'ellipse':
$rx = ($obj['rx'] ?? ($ow / 2)) * $sx;
$ry = ($obj['ry'] ?? ($oh / 2)) * $sy;
$pdf->Ellipse($ox + $rx, $oy + $ry, $rx, $ry, 0, 0, 360, 'D');
break;
case 'line':
$x1 = ($obj['x1'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx;
$y1 = ($obj['y1'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy;
$x2 = ($obj['x2'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx;
$y2 = ($obj['y2'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy;
$pdf->Line($x1, $y1, $x2, $y2);
break;
case 'path':
// Freihand-Pfade vereinfacht als Polyline rendern
if (!empty($obj['path']) && is_array($obj['path'])) {
$prev = null;
foreach ($obj['path'] as $seg) {
if (!is_array($seg) || count($seg) < 3) continue;
$cmd = $seg[0];
if ($cmd === 'M') {
$prev = array($seg[1] * $sx + $x, $seg[2] * $sy + $y);
} elseif (($cmd === 'L' || $cmd === 'Q') && $prev) {
$px = $seg[1] * $sx + $x;
$py = $seg[2] * $sy + $y;
$pdf->Line($prev[0], $prev[1], $px, $py);
$prev = array($px, $py);
}
}
}
break;
case 'i-text':
case 'text':
case 'textbox':
$fontsize = max(6, ($obj['fontSize'] ?? 16) * $sx * 2.83); // px → pt
$pdf->SetFont('helvetica', '', $fontsize);
$pdf->SetTextColor($stroke[0], $stroke[1], $stroke[2]);
$pdf->Text($ox, $oy + $fontsize * 0.35, $obj['text'] ?? '');
break;
}
}
}
function bericht_hex_to_rgb($hex)
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
}
if (strlen($hex) !== 6) return array(255, 0, 0);
return array(hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2)));
}