All checks were successful
Deploy bericht / deploy (push) Successful in 1s
Phase 1.6 Verknüpfte Sicht Auftrag↔Rechnung: - Bericht::fetchLinkedForElement liest llx_element_element-Verknüpfungen - linkToElement/unlinkFromElement n:m-API - Bericht-Übersicht zeigt drei Sektionen: direkt zugeordnet, zusätzlich verknüpft, aus verknüpften Aufträgen (read-only) - 'Übernehmen'-Button erstellt llx_element_element-Eintrag - 'Lösen'-Button entfernt Verknüpfung - generate_pdf legt das fertige PDF auch unter den verknüpften Elementen ab + ECM-Eintrag Phase 1.1 Live-PDF-Vorschau: - Neuer Endpoint ajax/preview_pdf.php — wie generate_pdf, aber: schreibt nicht in ECM, ändert nicht den Status, streamt direkt - 👁️ Vorschau-Button im Editor öffnet Modal mit iframe (PDF.js Viewer des Browsers) - bericht_burn_annotations und bericht_render_cover_internal in lib/bericht.lib.php verschoben (gemeinsam genutzt) - ESC-Key + Backdrop-Click schließen das Modal Phase 1.2 Anhänge löschen: - Neuer Endpoint ajax/delete_attachment.php mit Path-Whitelist (nur facture/, commande/, propal/), löscht Datei + thumbs + llx_ecm_files-Eintrag - 🗑️-Button in jeder Anhang-Zeile, Confirm-Dialog mit Quell-Auftrag/Rechnung im Text - Inline-Remove ohne Page-Reload Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> [deploy]
336 lines
13 KiB
PHP
336 lines
13 KiB
PHP
<?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);
|
|
} else {
|
|
return null;
|
|
}
|
|
if ($o->fetch($id) <= 0) return null;
|
|
$o->fetch_thirdparty();
|
|
if (method_exists($o, 'fetch_optionals')) {
|
|
$o->fetch_optionals();
|
|
}
|
|
return $o;
|
|
}
|
|
|
|
/**
|
|
* 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('helvetica', '', $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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|