bericht/lib/bericht.lib.php
Eduard Wisch ca2b796b36
All checks were successful
Deploy bericht / deploy (push) Successful in 6s
Feature: Lieferschein-Unterschrift via ODT-Hook + PWA-Signatur-Workflow
- Neuer API-Endpoint api/shipments.php: Liste Lieferungen zu Auftrag, PDF-Stream, confirm (Unterschrift stempeln)
- ODT-Hook actions_bericht.class.php: ersetzt {signature} Platzhalter via odfphp->setImage, setzt {signer_name}/{signed_at}/{gps}
- Backup-Roundtrip: generateDocument-Backup → signed.pdf erzeugen → Original wiederherstellen
- JWT-Fallback in _jwt.php: ?jwt= Query-Param für <img>/<object> ohne Authorization-Header
- Admin: BERICHT_SIGNATURE_IMAGE_RATIO Feld, Toggle BERICHT_TAB_ON_SHIPMENT, Signature-Box-Editor
- DB: llx_bericht_signature_box für pro-Template mm-Box-Geometrie
- element_type='shipment' in modBericht + lib/bericht.lib.php
- element_element Richtung: commande=source, shipping=target (fk_target=expedition_id)
- DOL_DATA_ROOT-Auflösung für EXPEDITION_ADDON_PDF_ODT_PATH
- Sprachen: de_DE + en_US mit neuen Schlüsseln für Signatur-Workflow

[deploy]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 06:48:42 +02:00

988 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* GPL v3+
*/
/**
* Hilfs-Funktionen für das Bericht-Modul.
*/
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
/**
* Erzeugt die Tab-Header-Leiste für die Editor-Seite (Übersicht / Editor).
*
* @param Bericht $object
* @return array
*/
function bericht_prepare_head($object)
{
global $langs, $conf;
$langs->load("bericht@bericht");
$h = 0;
$head = array();
$head[$h][0] = dol_buildpath('/bericht/bericht_card.php', 1).'?id='.$object->id;
$head[$h][1] = $langs->trans("BerichtEditor");
$head[$h][2] = 'editor';
$h++;
return $head;
}
/**
* Lädt das Parent-Objekt anhand des element_type.
*
* @return CommonObject|null
*/
function bericht_fetch_parent(DoliDB $db, $element_type, $id)
{
if ($element_type === 'invoice') {
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
$o = new Facture($db);
} elseif ($element_type === 'order') {
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
$o = new Commande($db);
} elseif ($element_type === 'propal') {
require_once DOL_DOCUMENT_ROOT.'/comm/propal/class/propal.class.php';
$o = new Propal($db);
} elseif ($element_type === 'shipment') {
require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php';
$o = new Expedition($db);
} else {
return null;
}
if ($o->fetch($id) <= 0) return null;
$o->fetch_thirdparty();
if (method_exists($o, 'fetch_optionals')) {
$o->fetch_optionals();
}
return $o;
}
/**
* Liefert die Expedition zu einer Lieferschein-ID und holt zusaetzlich den verknuepften Auftrag (commande)
* ueber llx_element_element. Resultat enthaelt: expedition, commande (optional), thirdparty.
*
* @return array|null ['expedition'=>Expedition,'commande'=>Commande|null,'thirdparty'=>Societe|null]
*/
function bericht_fetch_shipment_with_order(DoliDB $db, $expedition_id)
{
require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php';
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
$shipment = new Expedition($db);
if ($shipment->fetch((int) $expedition_id) <= 0) return null;
$shipment->fetch_thirdparty();
if (method_exists($shipment, 'fetch_optionals')) $shipment->fetch_optionals();
// Verknuepfung commande → shipping (commande ist source, shipping ist target)
$commande = null;
$sql = "SELECT fk_source FROM ".$db->prefix()."element_element"
." WHERE sourcetype = 'commande' AND targettype = 'shipping'"
." AND fk_target = ".((int) $expedition_id)
." LIMIT 1";
$r = $db->query($sql);
if ($r && ($row = $db->fetch_object($r))) {
$cmd = new Commande($db);
if ($cmd->fetch((int) $row->fk_source) > 0) {
$cmd->fetch_thirdparty();
if (method_exists($cmd, 'fetch_optionals')) $cmd->fetch_optionals();
$commande = $cmd;
}
}
return array(
'expedition' => $shipment,
'commande' => $commande,
'thirdparty' => $shipment->thirdparty ?: ($commande ? $commande->thirdparty : null),
);
}
/**
* Sucht das aktuell auf einer Expedition abgelegte Lieferschein-PDF.
* Liefert den absoluten Pfad oder null wenn nicht vorhanden.
*
* Mit $include_signed=false (Default) wird das "-signed.pdf" (das von uns gestempelte
* Resultat) ausgeschlossen — fuer die PWA-Vorschau wollen wir das Original, nicht den
* bereits signierten Lieferschein.
*
* @param bool $include_signed Wenn true, koennen -signed.pdf-Dateien als Treffer dienen
*/
function bericht_get_shipment_pdf(DoliDB $db, $shipment, $include_signed = false)
{
global $conf, $user, $langs;
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
if (empty($shipment) || empty($shipment->ref)) return null;
$ref_sane = dol_sanitizeFileName($shipment->ref);
$shipment_dir = $conf->expedition->multidir_output[$shipment->entity ?: $conf->entity].'/sending/'.$ref_sane;
if (!is_dir($shipment_dir)) {
$shipment_dir = $conf->expedition->multidir_output[$shipment->entity ?: $conf->entity].'/'.$ref_sane;
}
$is_signed = function ($path) {
return (bool) preg_match('/-signed\.pdf$/i', $path);
};
// 1) Bevorzugt <ref>.pdf direkt
$candidate = $shipment_dir.'/'.$ref_sane.'.pdf';
if (file_exists($candidate) && ($include_signed || !$is_signed($candidate))) return $candidate;
// 2) Wenn nichts da: generieren lassen
if (!is_dir($shipment_dir) || empty(glob($shipment_dir.'/*.pdf'))) {
$template = getDolGlobalString('EXPEDITION_ADDON_PDF', 'merou');
try { $shipment->generateDocument($template, $langs); }
catch (Throwable $e) { return null; }
}
// 3) PDFs im Verzeichnis filtern — signed ausschließen falls nicht erwünscht
if (is_dir($shipment_dir)) {
$pdfs = glob($shipment_dir.'/*.pdf') ?: array();
if (!$include_signed) {
$pdfs = array_values(array_filter($pdfs, function ($f) use ($is_signed) {
return !$is_signed($f);
}));
}
if (!empty($pdfs)) {
// Neueste zuerst (frisch generiert > alt)
usort($pdfs, function ($a, $b) { return filemtime($b) <=> filemtime($a); });
return $pdfs[0];
}
}
return null;
}
/**
* Liefert die Box-Geometrie fuer ein Lieferschein-Template.
* Nimmt zuerst llx_bericht_signature_box (template_name match), sonst Default-JSON-Konstante.
*
* @return array ['page'=>'first'|'last'|int, 'x_mm'=>float, 'y_mm'=>float, 'w_mm'=>float, 'h_mm'=>float, 'label'=>string]
*/
/**
* Rendert ein ODT-Lieferschein-Template mit Substitution:
* - Bild-Platzhalter "signature" (im ODT als Bild-Name gesetzt) → Kunden-Unterschrifts-PNG
* - Textvariablen werden via Odf::setVars befuellt:
* {signer_name}, {signed_at}, {gps}, {kunde_name}, {kunde_adresse}, {shipment_ref}, {order_ref}
* - LibreOffice headless konvertiert ODT → PDF
*
* @param string $odt_template_path absoluter Pfad zum Quell-ODT (doctemplates/shipments/*.odt)
* @param string $signature_png absoluter Pfad zum Unterschrifts-PNG
* @param array $meta Substitutions-Daten: signer_name, signed_at, gps_lat, gps_lon, shipment, order, kunde
* @param string $out_pdf absoluter Zielpfad fuer das fertige PDF
*
* @return bool true bei Erfolg
*/
function bericht_render_signed_shipment_odt($odt_template_path, $signature_png, array $meta, $out_pdf, &$debug = '')
{
if (!file_exists($odt_template_path)) { $debug = 'ODT-Template fehlt: '.$odt_template_path; return false; }
if (!file_exists($signature_png)) { $debug = 'Signatur-PNG fehlt: '.$signature_png; return false; }
$odt_loader = DOL_DOCUMENT_ROOT.'/includes/odtphp/odf.php';
if (!file_exists($odt_loader)) { $debug = 'odf.php-Loader fehlt: '.$odt_loader; return false; }
require_once $odt_loader;
$tempdir = DOL_DATA_ROOT.'/bericht/temp';
if (!is_dir($tempdir)) dol_mkdir($tempdir);
try {
$odf = new Odf($odt_template_path, array('PATH_TO_TMP' => $tempdir));
// Text-Platzhalter {signature} im ODT durch ein draw:frame mit der Kunden-Unterschrift ersetzen.
// setImage() rechnet Pixel × PIXEL_TO_CM × ratio — ratio steuert die Endgroesse im PDF.
// 1.0 = 1:1 bei 96dpi (zu gross fuer Signaturen). 0.3 ergibt ca. 6×3 cm bei 800×400 Canvas.
$ratio = (float) getDolGlobalString('BERICHT_SIGNATURE_IMAGE_RATIO', '0.35');
try {
$odf->setImage('signature', $signature_png, $ratio);
} catch (Throwable $e) {
// Wenn {signature}-Platzhalter im ODT fehlt, ist das ein Konfigurationsproblem —
// wir loggen es aber rendern trotzdem (die Textvars werden weiter ersetzt).
dol_syslog('bericht_render_signed_shipment_odt: setImage(signature) failed: '.$e->getMessage(), LOG_WARNING);
}
// Textvariablen — werden nur ersetzt wenn sie im ODT als {key} stehen, sonst ignoriert.
$shipment = $meta['shipment'] ?? null;
$order = $meta['order'] ?? null;
$kunde = $meta['kunde'] ?? null;
$gps_str = (isset($meta['gps_lat'], $meta['gps_lon']) && $meta['gps_lat'] !== null && $meta['gps_lon'] !== null)
? sprintf('%.6f, %.6f', $meta['gps_lat'], $meta['gps_lon'])
: '';
// Auftragsnummer: zuerst Extrafeld der Expedition (Eddys Computed-Field),
// dann Fallback Extrafeld der Commande, dann ref_client, dann commande->ref.
$auftragsnummer = '';
if ($shipment && !empty($shipment->array_options['options_auftragsnummer'])) {
$auftragsnummer = $shipment->array_options['options_auftragsnummer'];
} elseif ($order && !empty($order->array_options['options_auftragsnummer'])) {
$auftragsnummer = $order->array_options['options_auftragsnummer'];
} elseif ($order && !empty($order->ref_client)) {
$auftragsnummer = $order->ref_client;
} elseif ($order) {
$auftragsnummer = $order->ref;
}
$vars = array(
'signer_name' => (string) ($meta['signer_name'] ?? ''),
'signed_at' => (string) ($meta['signed_at'] ?? ''),
'gps' => $gps_str,
'shipment_ref' => $shipment ? $shipment->ref : '',
'order_ref' => $order ? $order->ref : '',
'auftragsnummer' => $auftragsnummer,
'kunde_name' => $kunde->name ?? '',
'kunde_adresse' => $kunde
? trim(($kunde->address ?? '')."\n".(($kunde->zip ?? '').' '.($kunde->town ?? '')))
: '',
'datum' => dol_print_date(dol_now(), 'day'),
);
// Alle Expedition-Extrafelder als {options_<name>} zusaetzlich bereitstellen
// (deckt Sonderfaelle ab wenn Eddy weitere Extrafelder im ODT nutzen will)
if ($shipment && !empty($shipment->array_options) && is_array($shipment->array_options)) {
foreach ($shipment->array_options as $k => $v) {
if (is_scalar($v) || $v === null) $vars[$k] = (string) ($v ?? '');
}
}
foreach ($vars as $k => $v) {
try { $odf->setVars($k, $v, true, 'UTF-8'); } catch (Throwable $e) { /* nicht alle Keys muessen im ODT existieren */ }
}
$odt_out = $tempdir.'/signed_shipment_'.uniqid().'.odt';
$odf->saveToDisk($odt_out);
if (!file_exists($odt_out)) { $debug = 'saveToDisk hat kein ODT erzeugt: '.$odt_out; return false; }
// LibreOffice headless ODT → PDF
// Pfad ermitteln: BERICHT_LIBREOFFICE_BIN oder via 'which'
$lobin = getDolGlobalString('BERICHT_LIBREOFFICE_BIN', '');
if (!$lobin || !is_executable($lobin)) {
// Try 'soffice' (Dolibarr-Standard) und 'libreoffice'
foreach (array('soffice', 'libreoffice') as $candidate) {
$found = @trim(@shell_exec('command -v '.escapeshellarg($candidate).' 2>/dev/null'));
if ($found && is_executable($found)) { $lobin = $found; break; }
}
}
if (!$lobin) { $debug = 'LibreOffice-Binary nicht gefunden. Setze BERICHT_LIBREOFFICE_BIN auf vollen Pfad zu soffice.'; return false; }
$tmp_outdir = $tempdir.'/lo_'.uniqid();
dol_mkdir($tmp_outdir);
// -env:UserInstallation noetig damit LO unter Apache-User starten kann (sonst /root/.config-Konflikte)
$user_profile = $tempdir.'/lo_profile_'.uniqid();
dol_mkdir($user_profile);
$cmd = escapeshellcmd($lobin)
.' --headless'
.' -env:UserInstallation=file://'.escapeshellarg($user_profile)
.' --convert-to pdf --outdir '.escapeshellarg($tmp_outdir)
.' '.escapeshellarg($odt_out).' 2>&1';
$lo_out = @shell_exec($cmd);
$pdf_generated = preg_replace('/\.odt$/i', '.pdf', basename($odt_out));
$pdf_full = $tmp_outdir.'/'.$pdf_generated;
$ok = false;
if (file_exists($pdf_full)) {
$out_dir = dirname($out_pdf);
if (!is_dir($out_dir)) dol_mkdir($out_dir);
$ok = @rename($pdf_full, $out_pdf);
if (!$ok) $ok = @copy($pdf_full, $out_pdf);
if (!$ok) $debug = 'rename/copy PDF nach Zielpfad fehlgeschlagen: '.$out_pdf;
} else {
$debug = 'LibreOffice hat kein PDF erzeugt. Befehl: '.$cmd.' | Output: '.substr((string) $lo_out, 0, 500);
}
// Aufraeumen
@unlink($odt_out);
if (is_dir($tmp_outdir)) {
foreach (glob($tmp_outdir.'/*') as $f) @unlink($f);
@rmdir($tmp_outdir);
}
if (is_dir($user_profile)) {
// rekursiv loeschen, LO erzeugt Subfolder
@exec('rm -rf '.escapeshellarg($user_profile));
}
return $ok && file_exists($out_pdf);
} catch (Throwable $e) {
$debug = 'Exception: '.$e->getMessage().' @ '.basename($e->getFile()).':'.$e->getLine();
dol_syslog('bericht_render_signed_shipment_odt: '.$debug, LOG_ERR);
return false;
}
}
/**
* Stempelt eine PNG-Signatur (plus Metadaten-Label) in ein vorhandenes Lieferschein-PDF.
* Nutzt FPDI zum Importieren der Originalseiten und TCPDF zum Daraufschreiben.
* Die Box-Geometrie kommt aus llx_bericht_signature_box (template_name match) oder Default.
*
* @param string $src_pdf absoluter Pfad zum Original-Lieferschein-PDF
* @param string $signature_png absoluter Pfad zum PNG der Unterschrift
* @param array $box ['page','x_mm','y_mm','w_mm','h_mm','label']
* @param array $meta ['signer_name','signed_at','gps_lat','gps_lon','shipment_ref']
* @param string $out_pdf absoluter Pfad fuer die Ausgabe
*
* @return bool true bei Erfolg
*/
function bericht_stamp_signature_on_pdf($src_pdf, $signature_png, array $box, array $meta, $out_pdf)
{
if (!file_exists($src_pdf) || !file_exists($signature_png)) return false;
// FPDI bei Bedarf laden
if (!class_exists('\\setasign\\Fpdi\\Tcpdf\\Fpdi')) {
$candidates = array(
DOL_DOCUMENT_ROOT.'/includes/setasign/vendor/autoload.php',
DOL_DOCUMENT_ROOT.'/includes/fpdi/autoload.php',
DOL_DOCUMENT_ROOT.'/includes/setasign/vendor/setasign/fpdi/src/Tcpdf/Fpdi.php',
);
foreach ($candidates as $c) {
if (file_exists($c)) { require_once $c; break; }
}
}
if (!class_exists('\\setasign\\Fpdi\\Tcpdf\\Fpdi')) return false;
try {
$pdf = new \setasign\Fpdi\Tcpdf\Fpdi();
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
$pdf->setAutoPageBreak(false, 0);
$pageCount = $pdf->setSourceFile($src_pdf);
// Ziel-Seite auswaehlen
$target_page = 1;
if (is_numeric($box['page'])) {
$target_page = max(1, min($pageCount, (int) $box['page']));
} elseif ($box['page'] === 'first') {
$target_page = 1;
} else { // 'last' oder beliebiger nicht-numerischer Wert
$target_page = $pageCount;
}
for ($p = 1; $p <= $pageCount; $p++) {
$tpl = $pdf->importPage($p);
$size = $pdf->getTemplateSize($tpl);
$orient = ($size['width'] > $size['height']) ? 'L' : 'P';
$pdf->AddPage($orient, array($size['width'], $size['height']));
$pdf->useTemplate($tpl);
if ($p === $target_page) {
$x = (float) $box['x_mm'];
$y = (float) $box['y_mm'];
$w = (float) $box['w_mm'];
$h = (float) $box['h_mm'];
$label = $box['label'] ?? 'Unterschrift Kunde';
// Rahmen der Box (zart, mehr als Hilfsmarkierung)
$pdf->SetDrawColor(120, 120, 120);
$pdf->SetLineWidth(0.15);
$pdf->Rect($x, $y, $w, $h, 'D');
// Label oberhalb der Box
$pdf->SetFont('helvetica', 'B', 8);
$pdf->SetTextColor(80, 80, 80);
$pdf->SetXY($x, max(0, $y - 4));
$pdf->Cell($w, 3.5, $label, 0, 0, 'L');
// PNG hinein, mit Padding und proportionaler Skalierung
$pad = 2;
$sigBoxW = max(1, $w - 2 * $pad);
$sigBoxH = max(1, $h - 2 * $pad - 6); // 6 mm fuer Footer-Text
list($iw, $ih) = @getimagesize($signature_png);
if ($iw && $ih) {
$ratio = min($sigBoxW / $iw, $sigBoxH / $ih);
$dw = $iw * $ratio;
$dh = $ih * $ratio;
$dx = $x + ($w - $dw) / 2;
$dy = $y + $pad;
$pdf->Image($signature_png, $dx, $dy, $dw, $dh, 'PNG');
}
// Trennlinie unter der Unterschrift
$sepY = $y + $h - 6;
$pdf->SetDrawColor(180, 180, 180);
$pdf->Line($x + $pad, $sepY, $x + $w - $pad, $sepY);
// Footer-Text: Name + Datum + GPS
$pdf->SetFont('helvetica', '', 6.5);
$pdf->SetTextColor(60, 60, 60);
$pdf->SetXY($x + $pad, $sepY + 0.5);
$line1 = trim(($meta['signer_name'] ?? '') . ($meta['signed_at'] ? ' · '.$meta['signed_at'] : ''));
$pdf->Cell($w - 2 * $pad, 3, $line1, 0, 0, 'L');
if (!empty($meta['gps_lat']) && !empty($meta['gps_lon'])) {
$pdf->SetXY($x + $pad, $sepY + 3);
$pdf->Cell($w - 2 * $pad, 3, sprintf('GPS: %.6f, %.6f', $meta['gps_lat'], $meta['gps_lon']), 0, 0, 'L');
}
}
}
$pdf->Output($out_pdf, 'F');
return file_exists($out_pdf);
} catch (Throwable $e) {
dol_syslog('bericht_stamp_signature_on_pdf: '.$e->getMessage(), LOG_ERR);
return false;
}
}
function bericht_get_signature_box(DoliDB $db, $template_name = '')
{
global $conf;
if ($template_name !== '') {
$sql = "SELECT page, x_mm, y_mm, w_mm, h_mm, label FROM ".$db->prefix()."bericht_signature_box"
." WHERE entity = ".((int) $conf->entity)
." AND template_name = '".$db->escape($template_name)."'"
." LIMIT 1";
$r = $db->query($sql);
if ($r && ($row = $db->fetch_object($r))) {
return array(
'page' => $row->page,
'x_mm' => (float) $row->x_mm,
'y_mm' => (float) $row->y_mm,
'w_mm' => (float) $row->w_mm,
'h_mm' => (float) $row->h_mm,
'label' => $row->label ?: 'Unterschrift Kunde',
);
}
}
// Default aus Konstante
$json = getDolGlobalString('BERICHT_SIGNATURE_BOX_DEFAULT', '');
$def = $json ? json_decode($json, true) : null;
if (!is_array($def)) {
$def = array('page' => 'last', 'x_mm' => 120, 'y_mm' => 230, 'w_mm' => 70, 'h_mm' => 35, 'label' => 'Unterschrift Kunde');
}
return $def;
}
/**
* Mappt einen element_type-Code auf den Dolibarr-internen Element-Namen
* für das Verzeichnis der Anhänge (multidir_output).
*/
function bericht_element_to_dir_key($element_type)
{
return array(
'invoice' => 'facture',
'order' => 'commande',
'propal' => 'propal',
)[$element_type] ?? null;
}
/**
* Sammelt alle Anhänge eines Parent-Objekts UND der direkt verknüpften Objekte
* (z. B. der Auftrag/Angebot, die mit dieser Rechnung verknüpft sind).
*
* @return array Liste mit ['source','source_id','source_ref','filename','fullpath','relpath','size','mime','date']
*/
function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_type)
{
global $conf;
$result = array();
// 1) Eigene Anhänge
$dir_key = bericht_element_to_dir_key($element_type);
if ($dir_key && !empty($conf->{$dir_key}->multidir_output[$parent->entity])) {
$upload_dir = $conf->{$dir_key}->multidir_output[$parent->entity].'/'.dol_sanitizeFileName($parent->ref);
if (is_dir($upload_dir)) {
$files = dol_dir_list($upload_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$');
foreach ($files as $f) {
$result[] = array(
'source' => $element_type,
'source_id' => $parent->id,
'source_ref' => $parent->ref,
'filename' => $f['name'],
'fullpath' => $f['fullname'],
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f['fullname']),
'size' => $f['size'],
'mime' => dol_mimetype($f['name']),
'date' => $f['date'],
);
}
}
}
// 2) Verknüpfte Objekte: order, propal — auch deren Anhänge anbieten
$parent->fetchObjectLinked();
$linked_types = array('commande' => 'order', 'propal' => 'propal', 'facture' => 'invoice');
foreach ($linked_types as $linked_dolibarr_type => $linked_module_type) {
if (empty($parent->linkedObjects[$linked_dolibarr_type])) continue;
foreach ($parent->linkedObjects[$linked_dolibarr_type] as $lobj) {
if (empty($conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity])) continue;
$linked_dir = $conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity].'/'.dol_sanitizeFileName($lobj->ref);
if (!is_dir($linked_dir)) continue;
$files = dol_dir_list($linked_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$');
foreach ($files as $f) {
$result[] = array(
'source' => $linked_module_type,
'source_id' => $lobj->id,
'source_ref' => $lobj->ref,
'filename' => $f['name'],
'fullpath' => $f['fullname'],
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f['fullname']),
'size' => $f['size'],
'mime' => dol_mimetype($f['name']),
'date' => $f['date'],
);
}
}
}
return $result;
}
/**
* Liest die Auftragsnummer aus dem Parent-Objekt.
* Reihenfolge: extrafield 'auftragsnummer' → ref_client → ref
*/
function bericht_get_auftragsnummer(CommonObject $parent)
{
if (!empty($parent->array_options['options_auftragsnummer'])) {
return $parent->array_options['options_auftragsnummer'];
}
if (!empty($parent->ref_client)) {
return $parent->ref_client;
}
return $parent->ref;
}
/**
* Listet alle ODT-Templates im Templates-Verzeichnis auf.
*
* @return string[] Dateinamen
*/
function bericht_list_templates()
{
$dir = DOL_DATA_ROOT.'/bericht/templates';
if (!is_dir($dir)) return array();
$files = scandir($dir);
return array_values(array_filter($files, function ($f) {
return preg_match('/\.odt$/i', $f);
}));
}
/**
* Sicherer absolute-Pfad-Resolver für Dateien unterhalb DOL_DATA_ROOT.
* Verhindert Path-Traversal.
*/
function bericht_resolve_data_path($relpath)
{
$base = realpath(DOL_DATA_ROOT);
$full = realpath($base.'/'.$relpath);
if ($full === false) return null;
if (strpos($full, $base) !== 0) return null;
return $full;
}
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)));
}
/**
* Rendert Fabric.js-Annotationen in ein TCPDF-Objekt.
* Nutzt die wichtigsten Shape-Typen: rect, circle/ellipse, line, path, text, group (Pfeil).
*/
function bericht_burn_annotations($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;
$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':
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 'group':
// Pfeil = Group(Line + Triangle) — als Pfeillinie rendern
if (!empty($obj['objects']) && is_array($obj['objects'])) {
$angle = ($obj['angle'] ?? 0) * M_PI / 180;
$startX = $ox; $startY = $oy;
// Länge aus den Children abschätzen
$len = 0;
foreach ($obj['objects'] as $child) {
if (($child['type'] ?? '') === 'line') {
$lx2 = ($child['x2'] ?? 0) - ($child['x1'] ?? 0);
$len = max($len, abs($lx2));
}
}
$len *= $sx * ($obj['scaleX'] ?? 1);
$endX = $startX + cos($angle) * $len;
$endY = $startY + sin($angle) * $len;
$pdf->Line($startX, $startY, $endX, $endY);
// Pfeilspitze
$headLen = max(2, $sw * 4);
$a1 = $angle + M_PI - 0.4;
$a2 = $angle + M_PI + 0.4;
$pdf->Line($endX, $endY, $endX + cos($a1) * $headLen, $endY + sin($a1) * $headLen);
$pdf->Line($endX, $endY, $endX + cos($a2) * $headLen, $endY + sin($a2) * $headLen);
}
break;
case 'i-text':
case 'text':
case 'textbox':
$fontsize = max(6, ($obj['fontSize'] ?? 16) * $sx * 2.83);
$pdf->SetFont(bericht_ensure_hack_font($pdf), '', $fontsize);
$pdf->SetTextColor($stroke[0], $stroke[1], $stroke[2]);
$pdf->Text($ox, $oy + $fontsize * 0.35, $obj['text'] ?? '');
break;
}
}
}
/**
* Rendert ein ODT-Template als PDF (Deckblatt).
* Nutzt Dolibarrs odtphp + LibreOffice headless für die Konvertierung.
*
* @return string|null Pfad zum erzeugten PDF oder null
*/
function bericht_render_cover_for_preview($template_path, $bericht, $parent, $tempdir)
{
return bericht_render_cover_internal($template_path, $bericht, $parent, $tempdir);
}
/**
* Rendert eine BerichtPage in das aktuelle TCPDF-Objekt.
* Behandelt: single (mit image_scale/align), grid_2, grid_2v, grid_4, grid_6, PDF-Quelle.
*
* @param TCPDF $pdf
* @param BerichtPage $page
* @param string $ori 'P' oder 'L'
* @param string $fmt A4/A3/A5/Letter
* @param bool $fpdi_loaded
*/
/**
* Registriert den Hack-Font (Eddys Corporate-Font) in TCPDF und gibt den Font-Key zurück.
* Konvertiert die TTFs beim ersten Aufruf via TCPDF_FONTS::addTTFfont ins TCPDF-Font-Verzeichnis.
* Fallback auf 'helvetica' wenn etwas schiefgeht.
*
* @return string Font-Key für $pdf->SetFont()
*/
function bericht_ensure_hack_font($pdf = null)
{
// Der Font wird in BerichtPdfTrait::berichtInit registriert; hier nur Key zurückgeben.
if ($pdf && isset($pdf->hack_font_key) && $pdf->hack_font_key) {
return $pdf->hack_font_key;
}
return 'helvetica';
}
/**
* Rendert eine HTML-Notiz (CKEditor-Output) mit TCPDF::writeHTMLCell
* in einen reservierten Bereich unterhalb des Bildes.
*/
function bericht_write_note_html($pdf, $html, $x, $y, $w, $h)
{
if (empty($html)) return;
$font = bericht_ensure_hack_font($pdf);
$pdf->SetFont($font, '', 9);
$pdf->SetTextColor(40, 40, 40);
$autoPB = $pdf->getAutoPageBreak();
$bMargin = $pdf->getBreakMargin();
$pdf->SetAutoPageBreak(false, 0);
// HTML in Font-Wrapper packen, damit TCPDF den Hack-Font für den ganzen Block nutzt
$wrapped = '<span style="font-family:'.$font.';font-size:9pt;">'.$html.'</span>';
$pdf->writeHTMLCell($w, $h, $x, $y, $wrapped, 0, 1, false, true, 'L', true);
$pdf->SetAutoPageBreak($autoPB, $bMargin);
}
function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
{
// Phase 6: Wenn der Editor ein Composite-PNG hochgeladen hat, nutzen wir das direkt
if (!empty($page->composite_path)) {
$full = bericht_resolve_data_path($page->composite_path);
if ($full && file_exists($full)) {
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
// Margins: oben Platz für Header (Logo/Titel/Firmenname), unten für Footer
$mL = 10; $mR = 10; $mT = 30; $mB = 16;
// Notiz-Bereich reservieren wenn eine Notiz existiert
$note_h = 0;
if (!empty($page->note)) {
$note_h = max(20, min(80, $pageH * 0.25));
}
$maxW = $pageW - $mL - $mR;
$maxH = $pageH - $mT - $mB - $note_h - ($note_h ? 4 : 0);
list($iw, $ih) = @getimagesize($full);
if ($iw && $ih) {
$ratio = min($maxW / $iw, $maxH / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
$x = ($pageW - $w) / 2;
$y = $mT;
$pdf->Image($full, $x, $y, $w, $h);
}
if (!empty($page->note)) {
$note_y = $mT + ($ih && $iw ? ($ih * min($maxW / $iw, $maxH / $ih)) : $maxH) + 4;
bericht_write_note_html($pdf, $page->note, $mL, $note_y, $maxW, $note_h);
}
return;
}
}
$layout = $page->layout ?: 'single';
// Spezial-Fall: reine Titel-Seite (kein Bild, nur großer Titel zentriert)
if ($layout === 'title_only' || ($layout === 'single' && empty($page->source_path) && !empty($page->title))) {
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 32);
$pdf->SetTextColor(40, 40, 40);
$pdf->SetY($pageH / 2 - 20);
$pdf->MultiCell(0, 20, $page->title, 0, 'C');
if (!empty($page->note)) {
$pdf->Ln(12);
$pdf->SetFont(bericht_ensure_hack_font($pdf), '', 12);
$pdf->MultiCell(0, 8, $page->note, 0, 'C');
}
return;
}
/* ----- Vorher/Nachher-Layout ----- */
if ($layout === 'before_after') {
$imgs = $page->getImages();
if (empty($imgs)) return;
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
$title_h = !empty($page->title) ? 12 : 0;
if ($title_h) {
$pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 16);
$pdf->SetY($margin);
$pdf->MultiCell(0, $title_h, $page->title, 0, 'C');
}
// Zwei Bilder nebeneinander mit VORHER/NACHHER Labels
$label_h = 8;
$gap = 6;
$top = $margin + $title_h + 4;
$usable_h = $pageH - $top - 10 - $label_h; // -10 für Notiz, -label_h für Beschriftung
$w_each = ($pageW - 2 * $margin - $gap) / 2;
foreach (array(array('Vorher', 0), array('Nachher', 1)) as $slot_info) {
list($label, $slot) = $slot_info;
if (!isset($imgs[$slot])) continue;
$full = bericht_resolve_data_path($imgs[$slot]['source_path']);
if (!$full || !file_exists($full)) continue;
list($iw, $ih) = @getimagesize($full);
if (!$iw || !$ih) continue;
$x0 = $margin + $slot * ($w_each + $gap);
// Label
$pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 11);
$pdf->SetXY($x0, $top);
$pdf->Cell($w_each, $label_h, $label, 0, 0, 'C');
// Bild darunter
$ratio = min($w_each / $iw, $usable_h / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
$x = $x0 + ($w_each - $w) / 2;
$y = $top + $label_h + ($usable_h - $h) / 2;
$pdf->Image($full, $x, $y, $w, $h);
}
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $pageW, $pageH);
if (!empty($page->note)) {
bericht_write_note_html($pdf, $page->note, 10, $pdf->getPageHeight() - 22, $pdf->getPageWidth() - 20, 18);
}
return;
}
/* ----- Multi-Image Grid ----- */
if ($layout !== 'single') {
$imgs = $page->getImages();
if (empty($imgs)) return;
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
// Titel oben
$title_h = 0;
if (!empty($page->title)) {
$pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 16);
$pdf->SetY(10);
$pdf->MultiCell(0, 10, $page->title, 0, 'C');
$title_h = 14;
}
$rects = BerichtPage::slotRects($layout, $pageW, $pageH - $title_h, 10 + $title_h);
foreach ($imgs as $img) {
$slot = $img['slot'];
if (!isset($rects[$slot])) continue;
$r = $rects[$slot];
$full = bericht_resolve_data_path($img['source_path']);
if (!$full || !file_exists($full)) continue;
list($iw, $ih) = @getimagesize($full);
if (!$iw || !$ih) continue;
$ratio = min($r['w'] / $iw, $r['h'] / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
$x = $r['x'] + ($r['w'] - $w) / 2;
$y = $r['y'] + ($r['h'] - $h) / 2;
$pdf->Image($full, $x, $y, $w, $h);
}
// Annotationen über volle Seite
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $pageW, $pageH);
// Notiz unten
if (!empty($page->note)) {
bericht_write_note_html($pdf, $page->note, 10, $pdf->getPageHeight() - 22, $pdf->getPageWidth() - 20, 18);
}
return;
}
/* ----- Single ----- */
$full = bericht_resolve_data_path($page->source_path);
if (!$full || !file_exists($full)) return;
if ($page->source_type === 'image' || preg_match('/\.(png|jpe?g)$/i', $full)) {
$pdf->AddPage($ori, $fmt);
list($iw, $ih) = @getimagesize($full);
if (!$iw || !$ih) return;
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
$maxW = ($pageW - 2 * $margin);
$maxH = ($pageH - 2 * $margin - 10); // Notiz unten
// image_scale: Anteil der maximalen Größe
$scale = max(0.2, min(1.0, (float) ($page->image_scale ?: 1.0)));
$availW = $maxW * $scale;
$availH = $maxH * $scale;
$ratio = min($availW / $iw, $availH / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
// image_align bestimmt die Position
list($x, $y) = bericht_align_position($page->image_align ?: 'fit', $w, $h, $pageW, $pageH, $margin);
$pdf->Image($full, $x, $y, $w, $h);
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);
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $size['width'], $size['height']);
} catch (Throwable $e) { /* skip */ }
}
if (!empty($page->note)) {
bericht_write_note_html($pdf, $page->note, 10, $pdf->getPageHeight() - 22, $pdf->getPageWidth() - 20, 18);
}
}
/**
* Berechnet die x/y-Position für ein Bild auf einer Seite anhand des image_align-Werts.
*/
function bericht_align_position($align, $w, $h, $pageW, $pageH, $margin = 10)
{
$usableW = $pageW - 2 * $margin;
$usableH = $pageH - 2 * $margin - 10; // Notiz unten
switch ($align) {
case 'topleft':
return array($margin, $margin);
case 'topright':
return array($pageW - $margin - $w, $margin);
case 'bottomleft':
return array($margin, $margin + $usableH - $h);
case 'bottomright':
return array($pageW - $margin - $w, $margin + $usableH - $h);
case 'center':
return array(($pageW - $w) / 2, $margin + ($usableH - $h) / 2);
case 'fit':
default:
return array(($pageW - $w) / 2, $margin);
}
}
function bericht_render_cover_internal($template_path, $bericht, $parent, $tempdir)
{
global $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.'_'.uniqid().'.odt';
$odf->saveToDisk($odt_out);
$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;
}
}