All checks were successful
Deploy bericht / deploy (push) Successful in 1s
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>
287 lines
11 KiB
PHP
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)));
|
|
}
|