Feature: Lieferschein-Unterschrift via ODT-Hook + PWA-Signatur-Workflow
All checks were successful
Deploy bericht / deploy (push) Successful in 6s
All checks were successful
Deploy bericht / deploy (push) Successful in 6s
- 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>
This commit is contained in:
parent
e462134a17
commit
ca2b796b36
14 changed files with 1543 additions and 3 deletions
44
ChangeLog.md
44
ChangeLog.md
|
|
@ -1,5 +1,49 @@
|
|||
# Changelog
|
||||
|
||||
## 1.2.0 — 2026-05-27
|
||||
|
||||
### Phase 1.8 Lieferschein-Bestätigung in der PWA
|
||||
|
||||
**Neuer element_type='shipment'**
|
||||
- Bericht-Modul unterstützt jetzt `element_type='shipment'` mit `fk_element = llx_expedition.rowid`
|
||||
- Tab "Bericht" erscheint auf der Lieferung-Card (Konstante `BERICHT_TAB_ON_SHIPMENT`)
|
||||
- `bericht_fetch_parent()` und `bericht_fetch_shipment_with_order()` laden Expedition + verknüpften Auftrag
|
||||
|
||||
**Signatur auf Lieferschein-PDF stempeln**
|
||||
- Neue Tabelle `llx_bericht_signature_box`: Pro PDF-Template (merou/rouget/espadon/...) konfigurierbare Box-Geometrie (x/y/w/h in mm, Seite first/last, Label)
|
||||
- Konstante `BERICHT_SIGNATURE_BOX_DEFAULT` als JSON-Default (`{"page":"last","x_mm":120,"y_mm":230,"w_mm":70,"h_mm":35,"label":"Unterschrift Kunde"}`)
|
||||
- `bericht_stamp_signature_on_pdf()`: FPDI importiert Original-Lieferschein, TCPDF stempelt PNG + Name + Datum + GPS in die konfigurierte Box
|
||||
|
||||
**Visueller Admin-Editor (`admin/signature_box_editor.php`)**
|
||||
- PDF.js rendert ein Beispiel-Lieferschein-PDF
|
||||
- Fabric.js-Rechteck zum Drag&Resize der Signatur-Box auf dem Canvas
|
||||
- Eingabefelder für X/Y/W/H/Label/Seite werden live synchronisiert
|
||||
- "Speichern" persistiert via AJAX (`ajax/save_signature_box.php`) in `llx_bericht_signature_box` (UPSERT auf `template_name`)
|
||||
- Reset-Button setzt zurück auf Default-Konstante
|
||||
|
||||
**Neue PWA-Route (Baustelle-App)**
|
||||
- Order-Detail bekommt Button "🚚 Lieferungen"
|
||||
- `#/orders/:id/shipments` zeigt Liste aller verknüpften Expeditionen mit Status- und Signed-Badge
|
||||
- `#/shipments/:id` zeigt Lieferschein-PDF inline (PDF.js Canvas) + großen Button "Lieferung unterschreiben lassen"
|
||||
|
||||
**Vollbild-Querformat-Signatur (`openShipmentSignatureModal`)**
|
||||
- `requestFullscreen()` + `screen.orientation.lock('landscape')` (Best-Effort)
|
||||
- 2-Spalten-Layout: links Lieferschein-Info + Namens-Input + GPS-Toggle, rechts Vollbild-Canvas
|
||||
- HiDPI-aware Canvas (`devicePixelRatio`), Pointer/Touch-Events mit quadratischer Glättung
|
||||
- Beim Bestätigen: PNG → `POST /api/shipments.php?action=confirm` → Backend stempelt in PDF und legt es in `documents/expedition/<ref>/<ref>-signed.pdf`
|
||||
|
||||
**Auto-Workflow auf der Expedition**
|
||||
- `signed_status=1` (via `CommonSignedObject::setSignedStatus`)
|
||||
- Wenn Expedition noch Draft (Status 0): erst `valid()`, dann `setClosed()`
|
||||
- Bericht-Record (`element_type='shipment'`) wird angelegt/aktualisiert mit `final_pdf_path` zum signierten PDF
|
||||
- `<sig>.meta.json` neben der PNG mit voller Audit-Spur (signer, user, GPS, IP, Box, Template, signed_at)
|
||||
|
||||
**Neue API-Endpoints**
|
||||
- `GET /api/shipments.php?order_id=<id>` — Liste der Lieferungen zu einem Auftrag
|
||||
- `GET /api/shipments.php?id=<id>` — Detail einer Lieferung + Bericht-ID falls vorhanden
|
||||
- `GET /api/shipments.php?id=<id>&action=pdf` — PDF-Stream (Original oder gestempelt)
|
||||
- `POST /api/shipments.php?id=<id>&action=confirm` — Unterschrift einstempeln + Status-Workflow
|
||||
|
||||
## 1.1.0 — 2026-04-08
|
||||
|
||||
### Phase 1 Bericht-Modul Erweiterungen
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ if ($action === 'save_const') {
|
|||
'BERICHT_TAB_ON_INVOICE' => GETPOST('tab_invoice', 'int') ? '1' : '0',
|
||||
'BERICHT_TAB_ON_ORDER' => GETPOST('tab_order', 'int') ? '1' : '0',
|
||||
'BERICHT_TAB_ON_PROPAL' => GETPOST('tab_propal', 'int') ? '1' : '0',
|
||||
'BERICHT_TAB_ON_SHIPMENT' => GETPOST('tab_shipment', 'int') ? '1' : '0',
|
||||
'BERICHT_SIGNATURE_IMAGE_RATIO' => str_replace(',', '.', (string) GETPOST('sig_ratio', 'alphanohtml')) ?: '0.35',
|
||||
'BERICHT_BURN_ANNOTATIONS' => GETPOST('burn', 'int') ? '1' : '0',
|
||||
'BERICHT_LIBREOFFICE_BIN' => GETPOST('lobin', 'alphanohtml'),
|
||||
'BERICHT_WHISPER_URL' => GETPOST('whisper_url', 'alphanohtml'),
|
||||
|
|
@ -105,6 +107,14 @@ print ' <p style="color:#e0e8f0;font-size:12px;margin:12px 0 0;">Mit dem Handy
|
|||
print '</div>';
|
||||
print '</div>';
|
||||
|
||||
// Lieferschein-Unterschrift Konfigurator
|
||||
print '<div class="bericht-setup-section">';
|
||||
print '<h3>'.$langs->trans("BerichtSignatureBoxConfig").'</h3>';
|
||||
print '<p class="opacitymedium">'.$langs->trans("BerichtSignatureBoxConfigDesc").'</p>';
|
||||
$editor_url = dol_buildpath('/bericht/admin/signature_box_editor.php', 1);
|
||||
print '<a href="'.dol_escape_htmltag($editor_url).'" class="butAction">✍️ '.$langs->trans("BerichtOpenSignatureBoxEditor").'</a>';
|
||||
print '</div>';
|
||||
|
||||
// Batch-Modus Link
|
||||
print '<div class="bericht-setup-section">';
|
||||
print '<h3>📦 Batch-Modus</h3>';
|
||||
|
|
@ -211,6 +221,12 @@ $cb = function ($name, $key, $default) {
|
|||
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabInvoice").'</td><td>'.$cb('tab_invoice', 'BERICHT_TAB_ON_INVOICE', '1').'</td></tr>';
|
||||
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabOrder").'</td><td>'.$cb('tab_order', 'BERICHT_TAB_ON_ORDER', '1').'</td></tr>';
|
||||
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabPropal").'</td><td>'.$cb('tab_propal', 'BERICHT_TAB_ON_PROPAL', '1').'</td></tr>';
|
||||
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabShipment").'</td><td>'.$cb('tab_shipment', 'BERICHT_TAB_ON_SHIPMENT', '1').'</td></tr>';
|
||||
$sig_ratio = getDolGlobalString('BERICHT_SIGNATURE_IMAGE_RATIO', '0.35');
|
||||
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupSignatureRatio").'</td><td>';
|
||||
print '<input type="number" step="0.05" min="0.05" max="2" name="sig_ratio" value="'.dol_escape_htmltag($sig_ratio).'" size="6">';
|
||||
print '<br><span class="opacitymedium small">'.$langs->trans("BerichtSetupSignatureRatioDesc").'</span>';
|
||||
print '</td></tr>';
|
||||
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupBurnAnnotations").'</td><td>'.$cb('burn', 'BERICHT_BURN_ANNOTATIONS', '1').'</td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupLibreOfficeBin").'</td><td>';
|
||||
|
|
|
|||
406
admin/signature_box_editor.php
Normal file
406
admin/signature_box_editor.php
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
* GPL v3+
|
||||
*
|
||||
* Visueller Editor fuer die Unterschriftsbox auf Lieferschein-PDFs.
|
||||
* Laedt ein Beispiel-Lieferschein-PDF (PDF.js), zeigt die aktuelle Box als
|
||||
* Fabric.js-Rechteck und speichert die mm-Geometrie in llx_bericht_signature_box.
|
||||
*/
|
||||
|
||||
$res = 0;
|
||||
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
|
||||
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; $tmp2 = realpath(__FILE__); $i = strlen($tmp) - 1; $j = strlen($tmp2) - 1;
|
||||
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; }
|
||||
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
|
||||
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
|
||||
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
|
||||
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
|
||||
if (!$res) die("Include of main fails");
|
||||
|
||||
require_once __DIR__.'/../lib/bericht.lib.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php';
|
||||
|
||||
if (!$user->admin && !$user->hasRight('bericht', 'admin')) accessforbidden();
|
||||
$langs->loadLangs(array("admin", "bericht@bericht"));
|
||||
|
||||
// Verfuegbare Templates: PDF-Module + ODT-Module + ODT-Dateien aus doctemplates/shipments/
|
||||
$tpl_dir = DOL_DOCUMENT_ROOT.'/core/modules/expedition/doc';
|
||||
$pdf_modules = array(); // ['espadon' => 'espadon', ...]
|
||||
$odt_modules = array(); // ['generic_shipment_odt' => 'generic_shipment_odt']
|
||||
if (is_dir($tpl_dir)) {
|
||||
foreach (glob($tpl_dir.'/pdf_*.modules.php') as $f) {
|
||||
$name = basename($f);
|
||||
if (preg_match('/^pdf_(.+)\.modules\.php$/', $name, $m)) {
|
||||
$pdf_modules[$m[1]] = $m[1];
|
||||
}
|
||||
}
|
||||
foreach (glob($tpl_dir.'/doc_*.modules.php') as $f) {
|
||||
$name = basename($f);
|
||||
if (preg_match('/^doc_(.+)\.modules\.php$/', $name, $m)) {
|
||||
$odt_modules[$m[1]] = $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ODT-Dateien aus doctemplates/shipments/ — pro Datei eine eigene Box konfigurierbar
|
||||
// EXPEDITION_ADDON_PDF_ODT_PATH enthaelt 'DOL_DATA_ROOT' woertlich + ist komma/zeilengetrennt
|
||||
$odt_files = array();
|
||||
$raw_dirs = getDolGlobalString('EXPEDITION_ADDON_PDF_ODT_PATH', 'DOL_DATA_ROOT/doctemplates/shipments');
|
||||
foreach (explode(',', preg_replace('/[\r\n]+/', ',', trim($raw_dirs))) as $d) {
|
||||
$d = trim($d);
|
||||
if (!$d) continue;
|
||||
$d = preg_replace('/DOL_DATA_ROOT/', DOL_DATA_ROOT, $d);
|
||||
if (!is_dir($d)) continue;
|
||||
foreach (glob($d.'/*.odt') as $f) {
|
||||
$name = basename($f);
|
||||
$key = 'odt:'.preg_replace('/\.odt$/i', '', $name);
|
||||
$odt_files[$key] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$active_template = getDolGlobalString('EXPEDITION_ADDON_PDF', 'merou');
|
||||
$selected = GETPOST('template', 'alphanohtml') ?: $active_template;
|
||||
|
||||
// Wenn nichts uebergeben + es gibt ODTs + Aktives Modul ist generic_shipment_odt → erstes ODT vorauswaehlen
|
||||
if (!GETPOST('template', 'alphanohtml') && $active_template === 'generic_shipment_odt' && !empty($odt_files)) {
|
||||
$selected = array_key_first($odt_files);
|
||||
}
|
||||
|
||||
// Auswahl validieren
|
||||
$all_choices = array_merge($pdf_modules, $odt_modules, $odt_files);
|
||||
if (!isset($all_choices[$selected])) {
|
||||
$selected = $active_template;
|
||||
if (!isset($all_choices[$selected]) && !empty($all_choices)) {
|
||||
$selected = array_key_first($all_choices);
|
||||
}
|
||||
}
|
||||
|
||||
// Aktuelle Box-Geometrie fuer das ausgewaehlte Template laden (oder Default)
|
||||
$box = bericht_get_signature_box($db, $selected);
|
||||
|
||||
// Letzte vorhandene Expedition fuer Preview suchen — sonst Hinweis ausgeben
|
||||
$preview_shipment_id = 0;
|
||||
$r = $db->query("SELECT rowid FROM ".$db->prefix()."expedition"
|
||||
." WHERE entity IN (".getEntity('expedition').")"
|
||||
." ORDER BY rowid DESC LIMIT 1");
|
||||
if ($r && ($o = $db->fetch_object($r))) $preview_shipment_id = (int) $o->rowid;
|
||||
|
||||
llxHeader('', $langs->trans("BerichtSignatureBoxConfig"));
|
||||
|
||||
$linkback = '<a href="'.dol_buildpath('/bericht/admin/setup.php', 1).'">'.$langs->trans("BackToSetup").'</a>';
|
||||
print load_fiche_titre($langs->trans("BerichtSignatureBoxConfig"), $linkback, 'title_setup');
|
||||
print '<p class="opacitymedium">'.$langs->trans("BerichtSignatureBoxConfigDesc").'</p>';
|
||||
|
||||
// Template-Wahl mit Gruppen
|
||||
print '<form method="get" style="margin-bottom:16px;">';
|
||||
print '<label style="margin-right:8px;">'.$langs->trans("BerichtPdfTemplate").':</label>';
|
||||
print '<select name="template" onchange="this.form.submit()">';
|
||||
if (!empty($odt_files)) {
|
||||
print '<optgroup label="'.dol_escape_htmltag($langs->trans("BerichtOdtFiles")).'">';
|
||||
foreach ($odt_files as $key => $filename) {
|
||||
$sel = ($key === $selected) ? ' selected' : '';
|
||||
$hint = ($active_template === 'generic_shipment_odt') ? ' ★' : '';
|
||||
print '<option value="'.dol_escape_htmltag($key).'"'.$sel.'>'.dol_escape_htmltag($filename).$hint.'</option>';
|
||||
}
|
||||
print '</optgroup>';
|
||||
}
|
||||
if (!empty($pdf_modules)) {
|
||||
print '<optgroup label="'.dol_escape_htmltag($langs->trans("BerichtPdfModules")).'">';
|
||||
foreach ($pdf_modules as $tpl) {
|
||||
$sel = ($tpl === $selected) ? ' selected' : '';
|
||||
$is_active = ($tpl === $active_template) ? ' ★' : '';
|
||||
print '<option value="'.dol_escape_htmltag($tpl).'"'.$sel.'>'.dol_escape_htmltag($tpl).$is_active.'</option>';
|
||||
}
|
||||
print '</optgroup>';
|
||||
}
|
||||
if (!empty($odt_modules)) {
|
||||
print '<optgroup label="'.dol_escape_htmltag($langs->trans("BerichtOdtModules")).'">';
|
||||
foreach ($odt_modules as $tpl) {
|
||||
$sel = ($tpl === $selected) ? ' selected' : '';
|
||||
$is_active = ($tpl === $active_template) ? ' ★' : '';
|
||||
print '<option value="'.dol_escape_htmltag($tpl).'"'.$sel.'>'.dol_escape_htmltag($tpl).$is_active.'</option>';
|
||||
}
|
||||
print '</optgroup>';
|
||||
}
|
||||
print '</select>';
|
||||
print ' <span class="opacitymedium" style="font-size:12px;">★ = aktuell in Dolibarr aktiv</span>';
|
||||
print '</form>';
|
||||
|
||||
// Hinweis bei ODT-Auswahl: mm-Editor ist hier NICHT relevant.
|
||||
// Stattdessen: Bild-Platzhalter "signature" im ODT einbauen — die Position waechst dann automatisch mit dem PDF-Inhalt mit.
|
||||
$is_odt = (strpos($selected, 'odt:') === 0);
|
||||
if ($is_odt) {
|
||||
$sig_ratio = getDolGlobalString('BERICHT_SIGNATURE_IMAGE_RATIO', '0.35');
|
||||
print '<div class="info" style="background:#1e3a5f;border:1px solid #4080c0;color:#e0e8f0;padding:14px 18px;border-radius:8px;margin-bottom:16px;font-size:14px;line-height:1.6;">';
|
||||
print '<h4 style="margin:0 0 8px;color:#fff;">📄 ODT-Template — Platzhalter-Methode (wie EPCQR/{qrcode})</h4>';
|
||||
print '<p style="margin:0;">Bei ODT-Vorlagen ist die mm-Box <strong>nicht sinnvoll</strong>: das PDF wird je nach Positionsmenge unterschiedlich lang — fixe Position würde mitten in den Zeilen landen.</p>';
|
||||
print '<p style="margin:10px 0 4px;font-weight:600;">Stattdessen:</p>';
|
||||
print '<ol style="margin:4px 0 0 18px;padding:0;">';
|
||||
print '<li>In deinem ODT (<code>'.dol_escape_htmltag($odt_files[$selected] ?? '').'</code>) an die gewünschte Stelle den Text-Platzhalter <code>{signature}</code> schreiben — irgendwo, wo die Unterschrift erscheinen soll.</li>';
|
||||
print '<li>Drumherum optional Text-Variablen einsetzen: <code>{signer_name}</code>, <code>{signed_at}</code>, <code>{gps}</code>, <code>{kunde_name}</code>, <code>{kunde_adresse}</code>, <code>{shipment_ref}</code>, <code>{order_ref}</code>, <code>{auftragsnummer}</code>, <code>{datum}</code>.</li>';
|
||||
print '<li>Speichern. Fertig.</li>';
|
||||
print '</ol>';
|
||||
print '<p style="margin:12px 0 4px;font-size:12px;opacity:0.85;">Beim Bestätigen in der PWA ersetzt <code>setImage()</code> den <code>{signature}</code>-Text durch ein <code><draw:frame></code> mit der Kunden-Unterschrift. Die Größe wird durch den Faktor <code>BERICHT_SIGNATURE_IMAGE_RATIO</code> (aktuell <strong>'.dol_escape_htmltag($sig_ratio).'</strong>) gesteuert — bei 0.35 ergibt das ca. 7×3.5 cm.</p>';
|
||||
print '<p style="margin:8px 0 0;font-size:12px;opacity:0.6;">Die mm-Felder rechts sind hier ohne Wirkung — sie gelten nur für reine PDF-Module (espadon/merou/rouget).</p>';
|
||||
print '</div>';
|
||||
}
|
||||
|
||||
if (!$preview_shipment_id) {
|
||||
print '<div class="warning">'.$langs->trans("BerichtSignatureBoxNoPreviewShipment").'</div>';
|
||||
} else {
|
||||
// PDF-URL fuer die Vorschau: nutzt den shipments.php-Endpoint mit JWT
|
||||
// Hier brauchen wir keinen JWT, weil wir admin sind und das PDF lokal lesen.
|
||||
// Stattdessen: Vorschau-Endpoint nimmt nur die Expedition-ID und laedt das PDF.
|
||||
$preview_url = dol_buildpath('/bericht/admin/signature_box_preview.php', 1)
|
||||
.'?id='.$preview_shipment_id
|
||||
.'&template='.urlencode($selected);
|
||||
|
||||
print '<div class="bericht-setup-section">';
|
||||
print '<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;">';
|
||||
|
||||
// Linke Spalte: PDF-Vorschau mit Box
|
||||
print '<div style="flex:1;min-width:400px;">';
|
||||
print '<h4>'.$langs->trans("BerichtPreviewWithBox").'</h4>';
|
||||
print '<style>'
|
||||
.'#pdf-container { position:relative; display:inline-block; line-height:0; }'
|
||||
// PDF zeichnen-Canvas: bleibt im Fluss
|
||||
.'#pdf-container > canvas:first-child { display:block; position:relative; z-index:1; }'
|
||||
// Fabric wraps in .canvas-container — der muss absolut OBEN drauf liegen
|
||||
.'#pdf-container .canvas-container { position:absolute !important; top:0 !important; left:0 !important; z-index:10 !important; }'
|
||||
// Innere Canvas (upper + lower) auch sauber stacked
|
||||
.'#pdf-container .canvas-container canvas { position:absolute; top:0; left:0; }'
|
||||
.'</style>';
|
||||
print '<div id="pdf-stage" style="background:#222;padding:8px;border-radius:6px;display:inline-block;max-width:100%;overflow:auto;">';
|
||||
print '<div id="pdf-container"></div>';
|
||||
print '</div>';
|
||||
print '<div style="margin-top:8px;display:flex;gap:8px;align-items:center;">';
|
||||
print '<button type="button" id="btn-prev-page" class="butAction">‹</button>';
|
||||
print '<span id="page-info">…</span>';
|
||||
print '<button type="button" id="btn-next-page" class="butAction">›</button>';
|
||||
print '</div>';
|
||||
print '</div>';
|
||||
|
||||
// Rechte Spalte: Werte editierbar + Speichern (bei ODT optisch gedimmt)
|
||||
$right_attr = $is_odt ? ' style="flex:0 0 280px;opacity:0.5;pointer-events:none;"' : ' style="flex:0 0 280px;"';
|
||||
print '<div'.$right_attr.'>';
|
||||
print '<h4>'.$langs->trans("BerichtBoxGeometry").($is_odt ? ' <span style="font-size:11px;font-weight:normal;">(bei ODT inaktiv)</span>' : '').'</h4>';
|
||||
print '<form id="box-form">';
|
||||
print '<input type="hidden" name="template_name" value="'.dol_escape_htmltag($selected).'">';
|
||||
print '<table class="noborder centpercent">';
|
||||
print '<tr><td>'.$langs->trans("BerichtBoxPage").'</td><td><select name="page">';
|
||||
foreach (array('last' => $langs->trans("BerichtBoxPageLast"), 'first' => $langs->trans("BerichtBoxPageFirst")) as $v => $l) {
|
||||
$sel = ($box['page'] == $v) ? ' selected' : '';
|
||||
print '<option value="'.$v.'"'.$sel.'>'.$l.'</option>';
|
||||
}
|
||||
print '</select></td></tr>';
|
||||
print '<tr><td>X (mm)</td><td><input type="number" step="0.5" name="x_mm" id="inp-x" value="'.((float) $box['x_mm']).'"></td></tr>';
|
||||
print '<tr><td>Y (mm)</td><td><input type="number" step="0.5" name="y_mm" id="inp-y" value="'.((float) $box['y_mm']).'"></td></tr>';
|
||||
print '<tr><td>'.$langs->trans("Width").' (mm)</td><td><input type="number" step="0.5" name="w_mm" id="inp-w" value="'.((float) $box['w_mm']).'"></td></tr>';
|
||||
print '<tr><td>'.$langs->trans("Height").' (mm)</td><td><input type="number" step="0.5" name="h_mm" id="inp-h" value="'.((float) $box['h_mm']).'"></td></tr>';
|
||||
print '<tr><td>'.$langs->trans("BerichtBoxLabel").'</td><td><input type="text" name="label" value="'.dol_escape_htmltag($box['label']).'"></td></tr>';
|
||||
print '</table>';
|
||||
print '<br>';
|
||||
print '<button type="button" id="btn-save-box" class="butAction">💾 '.$langs->trans("Save").'</button>';
|
||||
print '<button type="button" id="btn-reset-box" class="butAction" style="margin-left:8px;">'.$langs->trans("BerichtBoxResetDefault").'</button>';
|
||||
print '<div id="save-status" style="margin-top:8px;opacity:0.7;font-size:13px;"></div>';
|
||||
print '</form>';
|
||||
print '</div>'; // rechte Spalte
|
||||
print '</div>'; // Flex
|
||||
print '</div>'; // section
|
||||
|
||||
// PDF.js einbinden + Editor-JS
|
||||
$pdfjs_url = dol_buildpath('/bericht/js/lib/pdf.min.js', 1);
|
||||
$pdfjs_worker_url = dol_buildpath('/bericht/js/lib/pdf.worker.min.js', 1);
|
||||
$fabric_url = dol_buildpath('/bericht/js/lib/fabric.min.js', 1);
|
||||
$save_url = dol_buildpath('/bericht/ajax/save_signature_box.php', 1);
|
||||
?>
|
||||
<script src="<?php echo dol_escape_htmltag($pdfjs_url); ?>"></script>
|
||||
<script src="<?php echo dol_escape_htmltag($fabric_url); ?>"></script>
|
||||
<script>
|
||||
(function () {
|
||||
if (window.pdfjsLib) {
|
||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc = <?php echo json_encode($pdfjs_worker_url); ?>;
|
||||
}
|
||||
const PDF_URL = <?php echo json_encode($preview_url); ?>;
|
||||
const SAVE_URL = <?php echo json_encode($save_url); ?>;
|
||||
const TEMPLATE = <?php echo json_encode($selected); ?>;
|
||||
const IS_ODT = <?php echo json_encode($is_odt); ?>;
|
||||
const DEFAULT_BOX = <?php echo json_encode(bericht_get_signature_box($db, '')); ?>;
|
||||
// 1 PDF-Point = 25.4/72 mm
|
||||
const PT_TO_MM = 25.4 / 72.0;
|
||||
|
||||
let pdfDoc = null, pageNum = 1, pageCount = 0;
|
||||
let pageWmm = 210, pageHmm = 297;
|
||||
let renderScale = 1.0;
|
||||
let fabricCanvas = null, fabricRect = null;
|
||||
|
||||
const stage = document.getElementById('pdf-container');
|
||||
const inpX = document.getElementById('inp-x');
|
||||
const inpY = document.getElementById('inp-y');
|
||||
const inpW = document.getElementById('inp-w');
|
||||
const inpH = document.getElementById('inp-h');
|
||||
const pageInfo = document.getElementById('page-info');
|
||||
|
||||
function chosenPageNum() {
|
||||
const pageSel = document.querySelector('select[name="page"]').value;
|
||||
if (pageSel === 'first') return 1;
|
||||
return pageCount; // last
|
||||
}
|
||||
|
||||
async function loadPdf() {
|
||||
const buf = await fetch(PDF_URL, { credentials: 'same-origin' }).then(r => r.arrayBuffer());
|
||||
pdfDoc = await pdfjsLib.getDocument({ data: buf }).promise;
|
||||
pageCount = pdfDoc.numPages;
|
||||
pageNum = chosenPageNum();
|
||||
await renderPage();
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const viewport1 = page.getViewport({ scale: 1 });
|
||||
// PDF-Punkte → mm
|
||||
pageWmm = viewport1.width * PT_TO_MM;
|
||||
pageHmm = viewport1.height * PT_TO_MM;
|
||||
|
||||
// Skala so waehlen, dass max 700px breit
|
||||
const targetW = Math.min(700, window.innerWidth - 360);
|
||||
renderScale = targetW / viewport1.width;
|
||||
const viewport = page.getViewport({ scale: renderScale });
|
||||
|
||||
// Stage clear + Canvas zeichnen
|
||||
stage.innerHTML = '';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.style.display = 'block';
|
||||
stage.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
|
||||
|
||||
// Fabric-Layer obendrueber
|
||||
const fabricEl = document.createElement('canvas');
|
||||
fabricEl.width = viewport.width;
|
||||
fabricEl.height = viewport.height;
|
||||
fabricEl.style.position = 'absolute';
|
||||
fabricEl.style.top = '0';
|
||||
fabricEl.style.left = '0';
|
||||
stage.appendChild(fabricEl);
|
||||
|
||||
if (fabricCanvas) { try { fabricCanvas.dispose(); } catch (e) {} }
|
||||
fabricCanvas = new fabric.Canvas(fabricEl, { selection: false });
|
||||
// Container-Geometrie auf PDF-Canvas-Groesse fixieren, damit das Fabric-Overlay
|
||||
// (vom CSS '#pdf-container .canvas-container' absolut positioniert) deckt.
|
||||
stage.style.height = viewport.height + 'px';
|
||||
stage.style.width = viewport.width + 'px';
|
||||
const fabricWrap = stage.querySelector('.canvas-container');
|
||||
if (fabricWrap) {
|
||||
fabricWrap.style.width = viewport.width + 'px';
|
||||
fabricWrap.style.height = viewport.height + 'px';
|
||||
}
|
||||
|
||||
// Box aus aktuellen Werten
|
||||
drawBoxFromInputs();
|
||||
|
||||
pageInfo.textContent = 'Seite ' + pageNum + ' / ' + pageCount + ' (' + pageWmm.toFixed(0) + ' × ' + pageHmm.toFixed(0) + ' mm)';
|
||||
}
|
||||
|
||||
function drawBoxFromInputs() {
|
||||
if (!fabricCanvas) return;
|
||||
// ODT: keine Box zeichnen — Position kommt aus dem ODT-Bildplatzhalter
|
||||
if (IS_ODT) {
|
||||
fabricCanvas.clear();
|
||||
fabricRect = null;
|
||||
return;
|
||||
}
|
||||
// Wenn Seite nicht der Box-Seite entspricht: keine Box zeigen
|
||||
if (pageNum !== chosenPageNum()) {
|
||||
fabricCanvas.clear();
|
||||
fabricRect = null;
|
||||
return;
|
||||
}
|
||||
const xmm = parseFloat(inpX.value) || 0;
|
||||
const ymm = parseFloat(inpY.value) || 0;
|
||||
const wmm = parseFloat(inpW.value) || 50;
|
||||
const hmm = parseFloat(inpH.value) || 25;
|
||||
|
||||
// mm → px = mm / PT_TO_MM * renderScale
|
||||
const mm2px = (mm) => (mm / PT_TO_MM) * renderScale;
|
||||
|
||||
fabricCanvas.clear();
|
||||
fabricRect = new fabric.Rect({
|
||||
left: mm2px(xmm),
|
||||
top: mm2px(ymm),
|
||||
width: mm2px(wmm),
|
||||
height: mm2px(hmm),
|
||||
fill: 'rgba(60,140,220,0.20)',
|
||||
stroke: '#1e6fc0',
|
||||
strokeWidth: 2,
|
||||
cornerColor: '#1e6fc0',
|
||||
transparentCorners: false,
|
||||
hasRotatingPoint: false,
|
||||
lockRotation: true,
|
||||
});
|
||||
fabricCanvas.add(fabricRect);
|
||||
fabricCanvas.setActiveObject(fabricRect);
|
||||
|
||||
fabricRect.on('modified', syncInputsFromRect);
|
||||
fabricRect.on('moving', syncInputsFromRect);
|
||||
fabricRect.on('scaling', syncInputsFromRect);
|
||||
}
|
||||
|
||||
function syncInputsFromRect() {
|
||||
if (!fabricRect) return;
|
||||
const px2mm = (px) => (px / renderScale) * PT_TO_MM;
|
||||
const wPx = fabricRect.width * (fabricRect.scaleX || 1);
|
||||
const hPx = fabricRect.height * (fabricRect.scaleY || 1);
|
||||
inpX.value = px2mm(fabricRect.left).toFixed(1);
|
||||
inpY.value = px2mm(fabricRect.top).toFixed(1);
|
||||
inpW.value = px2mm(wPx).toFixed(1);
|
||||
inpH.value = px2mm(hPx).toFixed(1);
|
||||
}
|
||||
|
||||
// Bei Number-Input-Aenderung Box neu zeichnen
|
||||
[inpX, inpY, inpW, inpH].forEach(el => el.addEventListener('input', drawBoxFromInputs));
|
||||
document.querySelector('select[name="page"]').addEventListener('change', () => {
|
||||
pageNum = chosenPageNum();
|
||||
renderPage();
|
||||
});
|
||||
|
||||
document.getElementById('btn-prev-page').onclick = () => { if (pageNum > 1) { pageNum--; renderPage(); } };
|
||||
document.getElementById('btn-next-page').onclick = () => { if (pageNum < pageCount) { pageNum++; renderPage(); } };
|
||||
|
||||
document.getElementById('btn-reset-box').onclick = () => {
|
||||
inpX.value = DEFAULT_BOX.x_mm;
|
||||
inpY.value = DEFAULT_BOX.y_mm;
|
||||
inpW.value = DEFAULT_BOX.w_mm;
|
||||
inpH.value = DEFAULT_BOX.h_mm;
|
||||
document.querySelector('input[name="label"]').value = DEFAULT_BOX.label || 'Unterschrift Kunde';
|
||||
document.querySelector('select[name="page"]').value = DEFAULT_BOX.page || 'last';
|
||||
pageNum = chosenPageNum();
|
||||
renderPage();
|
||||
};
|
||||
|
||||
document.getElementById('btn-save-box').onclick = async () => {
|
||||
const status = document.getElementById('save-status');
|
||||
status.textContent = 'Speichere…';
|
||||
const fd = new FormData(document.getElementById('box-form'));
|
||||
fd.append('token', <?php echo json_encode(newToken()); ?>);
|
||||
try {
|
||||
const r = await fetch(SAVE_URL, { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (j.ok) status.textContent = '✓ Gespeichert um ' + new Date().toLocaleTimeString();
|
||||
else status.textContent = '✕ ' + (j.error || 'Fehler');
|
||||
} catch (e) {
|
||||
status.textContent = '✕ ' + e.message;
|
||||
}
|
||||
};
|
||||
|
||||
loadPdf();
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
llxFooter();
|
||||
$db->close();
|
||||
82
admin/signature_box_preview.php
Normal file
82
admin/signature_box_preview.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
* GPL v3+
|
||||
*
|
||||
* Liefert ein Beispiel-Lieferschein-PDF fuer den signature_box_editor.
|
||||
* Nur fuer eingeloggte Admins / bericht-admin Rechte.
|
||||
*/
|
||||
|
||||
$res = 0;
|
||||
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
|
||||
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; $tmp2 = realpath(__FILE__); $i = strlen($tmp) - 1; $j = strlen($tmp2) - 1;
|
||||
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; }
|
||||
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
|
||||
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
|
||||
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
|
||||
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
|
||||
if (!$res) die("Include of main fails");
|
||||
|
||||
require_once __DIR__.'/../lib/bericht.lib.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php';
|
||||
|
||||
if (!$user->admin && !$user->hasRight('bericht', 'admin')) accessforbidden();
|
||||
|
||||
$id = (int) GETPOST('id', 'int');
|
||||
$template = GETPOST('template', 'alphanohtml');
|
||||
if (!$id) { http_response_code(400); exit('id fehlt'); }
|
||||
|
||||
$shipment = new Expedition($db);
|
||||
if ($shipment->fetch($id) <= 0) { http_response_code(404); exit('Lieferung nicht gefunden'); }
|
||||
$shipment->fetch_thirdparty();
|
||||
|
||||
// Vorschau-PDF generieren mit dem ausgewaehlten Template — Overrides nur request-lokal
|
||||
// (keine dolibarr_set_const, sonst persistieren wir DB-Konstanten ungewollt).
|
||||
$pdf_path = null;
|
||||
if ($template) {
|
||||
if (strpos($template, 'odt:') === 0) {
|
||||
// ODT-Datei aus doctemplates/shipments/ — generic_shipment_odt nimmt das erste ODT im konfigurierten Pfad.
|
||||
// Wir kopieren das gewuenschte ODT in einen tempo-Pfad und stellen den ODT_PATH request-lokal um.
|
||||
$odt_filename = substr($template, 4).'.odt';
|
||||
$odt_full = null;
|
||||
$raw_dirs = getDolGlobalString('EXPEDITION_ADDON_PDF_ODT_PATH', 'DOL_DATA_ROOT/doctemplates/shipments');
|
||||
foreach (explode(',', preg_replace('/[\r\n]+/', ',', trim($raw_dirs))) as $d) {
|
||||
$d = trim($d);
|
||||
if (!$d) continue;
|
||||
$d = preg_replace('/DOL_DATA_ROOT/', DOL_DATA_ROOT, $d);
|
||||
if (file_exists($d.'/'.$odt_filename)) { $odt_full = $d.'/'.$odt_filename; break; }
|
||||
}
|
||||
if ($odt_full) {
|
||||
$tmp_tpl_dir = DOL_DATA_ROOT.'/bericht/temp/sigbox_tpl_'.uniqid();
|
||||
dol_mkdir($tmp_tpl_dir);
|
||||
@copy($odt_full, $tmp_tpl_dir.'/'.$odt_filename);
|
||||
|
||||
// Nur in-memory overriden — schreibt NICHT in llx_const
|
||||
$old_path = $conf->global->EXPEDITION_ADDON_PDF_ODT_PATH ?? '';
|
||||
$conf->global->EXPEDITION_ADDON_PDF_ODT_PATH = $tmp_tpl_dir;
|
||||
try {
|
||||
$shipment->generateDocument('generic_shipment_odt', $langs);
|
||||
} catch (Throwable $e) {
|
||||
dol_syslog('signature_box_preview ODT: '.$e->getMessage(), LOG_ERR);
|
||||
}
|
||||
$conf->global->EXPEDITION_ADDON_PDF_ODT_PATH = $old_path;
|
||||
@unlink($tmp_tpl_dir.'/'.$odt_filename);
|
||||
@rmdir($tmp_tpl_dir);
|
||||
|
||||
$pdf_path = bericht_get_shipment_pdf($db, $shipment);
|
||||
}
|
||||
} else {
|
||||
// PDF/ODT-Modul direkt
|
||||
try { $shipment->generateDocument($template, $langs); } catch (Throwable $e) {}
|
||||
$pdf_path = bericht_get_shipment_pdf($db, $shipment);
|
||||
}
|
||||
} else {
|
||||
$pdf_path = bericht_get_shipment_pdf($db, $shipment);
|
||||
}
|
||||
|
||||
if (!$pdf_path || !file_exists($pdf_path)) { http_response_code(404); exit('Lieferschein-PDF nicht verfuegbar'); }
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="'.basename($pdf_path).'"');
|
||||
header('Content-Length: '.filesize($pdf_path));
|
||||
readfile($pdf_path);
|
||||
exit;
|
||||
67
ajax/save_signature_box.php
Normal file
67
ajax/save_signature_box.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
* GPL v3+
|
||||
*
|
||||
* Speichert die Geometrie der Unterschriftsbox in llx_bericht_signature_box.
|
||||
* UNIQUE-Index (entity, template_name) → ON DUPLICATE KEY UPDATE.
|
||||
*/
|
||||
|
||||
require_once __DIR__.'/_inc.php';
|
||||
|
||||
if (!$user->admin && !$user->hasRight('bericht', 'admin')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(array('error' => 'Forbidden'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Token-Check (Dolibarr-Standard: Session-Token im POST muss matchen)
|
||||
$posted_token = GETPOST('token', 'alpha');
|
||||
if (!$posted_token || !isset($_SESSION['token']) || $posted_token !== $_SESSION['token']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(array('error' => 'CSRF-Token ungueltig'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$template = trim((string) GETPOST('template_name', 'alphanohtml'));
|
||||
$page = trim((string) GETPOST('page', 'alphanohtml')) ?: 'last';
|
||||
$x_mm = (float) GETPOST('x_mm', 'alpha');
|
||||
$y_mm = (float) GETPOST('y_mm', 'alpha');
|
||||
$w_mm = (float) GETPOST('w_mm', 'alpha');
|
||||
$h_mm = (float) GETPOST('h_mm', 'alpha');
|
||||
$label = trim((string) GETPOST('label', 'restricthtml')) ?: 'Unterschrift Kunde';
|
||||
|
||||
if ($template === '' || $w_mm <= 0 || $h_mm <= 0) {
|
||||
echo json_encode(array('error' => 'Ungueltige Werte'));
|
||||
exit;
|
||||
}
|
||||
$allowed_pages = array('first', 'last');
|
||||
if (!in_array($page, $allowed_pages, true) && !ctype_digit((string) $page)) {
|
||||
$page = 'last';
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO ".$db->prefix()."bericht_signature_box"
|
||||
." (entity, template_name, page, x_mm, y_mm, w_mm, h_mm, label, fk_user_modif)"
|
||||
." VALUES ("
|
||||
.((int) $conf->entity).","
|
||||
."'".$db->escape($template)."',"
|
||||
."'".$db->escape($page)."',"
|
||||
.((float) $x_mm).","
|
||||
.((float) $y_mm).","
|
||||
.((float) $w_mm).","
|
||||
.((float) $h_mm).","
|
||||
."'".$db->escape($label)."',"
|
||||
.((int) $user->id)
|
||||
.") ON DUPLICATE KEY UPDATE "
|
||||
."page='".$db->escape($page)."',"
|
||||
."x_mm=".((float) $x_mm).","
|
||||
."y_mm=".((float) $y_mm).","
|
||||
."w_mm=".((float) $w_mm).","
|
||||
."h_mm=".((float) $h_mm).","
|
||||
."label='".$db->escape($label)."',"
|
||||
."fk_user_modif=".((int) $user->id);
|
||||
|
||||
if (!$db->query($sql)) {
|
||||
echo json_encode(array('error' => $db->lasterror()));
|
||||
exit;
|
||||
}
|
||||
echo json_encode(array('ok' => true));
|
||||
10
api/_jwt.php
10
api/_jwt.php
|
|
@ -56,11 +56,19 @@ function bericht_jwt_from_request()
|
|||
$hdr = '';
|
||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
$hdr = $_SERVER['HTTP_AUTHORIZATION'];
|
||||
} elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||
$hdr = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
||||
} elseif (function_exists('apache_request_headers')) {
|
||||
$h = apache_request_headers();
|
||||
if (isset($h['Authorization'])) $hdr = $h['Authorization'];
|
||||
}
|
||||
if (!$hdr || stripos($hdr, 'bearer ') !== 0) return null;
|
||||
$token = '';
|
||||
if ($hdr && stripos($hdr, 'bearer ') === 0) {
|
||||
$token = trim(substr($hdr, 7));
|
||||
} elseif (!empty($_GET['jwt'])) {
|
||||
// Fallback: JWT als Query-Param (fuer <img>, <object>, <iframe> die keinen Authorization-Header schicken koennen)
|
||||
$token = (string) $_GET['jwt'];
|
||||
}
|
||||
if (!$token) return null;
|
||||
return bericht_jwt_decode($token);
|
||||
}
|
||||
|
|
|
|||
344
api/shipments.php
Normal file
344
api/shipments.php
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
<?php
|
||||
/* Lieferschein-Endpoints fuer die PWA.
|
||||
*
|
||||
* GET /api/shipments.php?order_id=<id> - Liste der Lieferungen zu einem Auftrag
|
||||
* GET /api/shipments.php?id=<id> - Detail einer Lieferung (mit Bericht-Info wenn vorhanden)
|
||||
* GET /api/shipments.php?id=<id>&action=pdf - Lieferschein-PDF (Original oder unterschrieben)
|
||||
* POST /api/shipments.php?id=<id>&action=confirm
|
||||
* multipart: file=<PNG Signatur>, signer_name, gps_lat?, gps_lon?
|
||||
* → stempelt PNG in das Lieferschein-PDF (Position aus llx_bericht_signature_box bzw. Default),
|
||||
* speichert Bericht (element_type='shipment'), setzt signed_status=1,
|
||||
* validiert+schliesst die Expedition wenn sie noch im Draft war.
|
||||
*/
|
||||
require_once __DIR__.'/_inc.php';
|
||||
|
||||
// Robust gegen unerwartete Fehler — sonst landet PHP-Fatal als leerer 500 ohne Hinweis
|
||||
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
|
||||
if (!(error_reporting() & $errno)) return false;
|
||||
api_fail('PHP-Error: '.$errstr.' in '.basename($errfile).':'.$errline, 500);
|
||||
return true;
|
||||
});
|
||||
set_exception_handler(function ($e) {
|
||||
api_fail('Exception: '.$e->getMessage().' in '.basename($e->getFile()).':'.$e->getLine(), 500);
|
||||
});
|
||||
|
||||
api_authenticate();
|
||||
global $db, $user, $conf, $langs;
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
|
||||
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
$order_id = (int) ($_GET['order_id'] ?? 0);
|
||||
$action = $_GET['action'] ?? '';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
/* ----- LISTE der Lieferungen zu einem Auftrag ----- */
|
||||
if ($order_id > 0 && !$id) {
|
||||
// llx_element_element: commande = source, shipping = target
|
||||
$sql = "SELECT e.rowid, e.ref, e.date_creation, e.date_delivery, e.date_expedition,"
|
||||
." e.fk_statut, e.signed_status, e.tracking_number, e.fk_soc,"
|
||||
." (SELECT COUNT(*) FROM ".$db->prefix()."bericht b WHERE b.element_type='shipment' AND b.fk_element=e.rowid) AS bericht_count"
|
||||
." FROM ".$db->prefix()."expedition e"
|
||||
." JOIN ".$db->prefix()."element_element ee ON ee.fk_target = e.rowid AND ee.targettype = 'shipping'"
|
||||
." WHERE ee.fk_source = ".((int) $order_id)." AND ee.sourcetype = 'commande'"
|
||||
." AND e.entity IN (".getEntity('expedition').")"
|
||||
." ORDER BY e.date_creation DESC, e.rowid DESC";
|
||||
$r = $db->query($sql);
|
||||
if (!$r) api_fail('DB-Fehler: '.$db->lasterror(), 500);
|
||||
|
||||
$out = array();
|
||||
while ($o = $db->fetch_object($r)) {
|
||||
$out[] = array(
|
||||
'id' => (int) $o->rowid,
|
||||
'ref' => $o->ref,
|
||||
'date_creation' => $db->jdate($o->date_creation),
|
||||
'date_delivery' => $db->jdate($o->date_delivery),
|
||||
'date_expedition' => $db->jdate($o->date_expedition),
|
||||
'status' => (int) $o->fk_statut,
|
||||
'signed_status' => $o->signed_status !== null ? (int) $o->signed_status : null,
|
||||
'tracking_number' => $o->tracking_number,
|
||||
'soc_id' => (int) $o->fk_soc,
|
||||
'bericht_count' => (int) $o->bericht_count,
|
||||
);
|
||||
}
|
||||
api_ok(array('shipments' => $out, 'count' => count($out)));
|
||||
}
|
||||
|
||||
if ($id <= 0) api_fail('id oder order_id fehlt');
|
||||
|
||||
// Lieferung + verknuepfter Auftrag laden
|
||||
$ctx = bericht_fetch_shipment_with_order($db, $id);
|
||||
if (!$ctx) api_fail('Lieferung nicht gefunden', 404);
|
||||
$shipment = $ctx['expedition'];
|
||||
$commande = $ctx['commande'];
|
||||
$kunde = $ctx['thirdparty'];
|
||||
|
||||
/* ----- PDF-Stream -----
|
||||
* Variante: ?variant=signed | unsigned | auto (default = auto: signed wenn signiert, sonst Original)
|
||||
*/
|
||||
if ($action === 'pdf' && $method === 'GET') {
|
||||
$variant = $_GET['variant'] ?? 'auto';
|
||||
$shipment_signed = (int) ($shipment->signed_status ?? 0) === 1;
|
||||
|
||||
$pdf_path = null;
|
||||
if ($variant === 'signed' || ($variant === 'auto' && $shipment_signed)) {
|
||||
// Suche explizit nach <ref>-signed.pdf
|
||||
$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;
|
||||
}
|
||||
$candidate = $shipment_dir.'/'.$ref_sane.'-signed.pdf';
|
||||
if (file_exists($candidate)) $pdf_path = $candidate;
|
||||
}
|
||||
if (!$pdf_path) {
|
||||
// Fallback / unsigned-Variante
|
||||
$pdf_path = bericht_get_shipment_pdf($db, $shipment, false);
|
||||
}
|
||||
if (!$pdf_path || !file_exists($pdf_path)) api_fail('Lieferschein-PDF nicht verfuegbar', 404);
|
||||
|
||||
// Header zuruecksetzen — _inc.php hat application/json gesetzt
|
||||
header_remove('Content-Type');
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="'.basename($pdf_path).'"');
|
||||
header('Content-Length: '.filesize($pdf_path));
|
||||
readfile($pdf_path);
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ----- DETAIL einer Lieferung ----- */
|
||||
if ($method === 'GET' && !$action) {
|
||||
$bericht_id = 0;
|
||||
$bres = $db->query("SELECT rowid FROM ".$db->prefix()."bericht"
|
||||
." WHERE element_type = 'shipment' AND fk_element = ".((int) $id)
|
||||
." ORDER BY datec DESC LIMIT 1");
|
||||
if ($bres && ($br = $db->fetch_object($bres))) $bericht_id = (int) $br->rowid;
|
||||
|
||||
api_ok(array(
|
||||
'shipment' => array(
|
||||
'id' => (int) $shipment->id,
|
||||
'ref' => $shipment->ref,
|
||||
'date_creation' => $shipment->date_creation,
|
||||
'date_delivery' => $shipment->date_delivery ?? null,
|
||||
'date_expedition' => $shipment->date_expedition ?? null,
|
||||
'status' => (int) ($shipment->statut ?? $shipment->fk_statut ?? 0),
|
||||
'signed_status' => isset($shipment->signed_status) ? (int) $shipment->signed_status : null,
|
||||
'tracking_number' => $shipment->tracking_number ?? null,
|
||||
),
|
||||
'order' => $commande ? array(
|
||||
'id' => (int) $commande->id,
|
||||
'ref' => $commande->ref,
|
||||
) : null,
|
||||
'customer' => array(
|
||||
'id' => (int) ($kunde->id ?? 0),
|
||||
'name' => $kunde->name ?? '',
|
||||
'address' => $kunde->address ?? '',
|
||||
'zip' => $kunde->zip ?? '',
|
||||
'town' => $kunde->town ?? '',
|
||||
),
|
||||
'bericht_id' => $bericht_id,
|
||||
));
|
||||
}
|
||||
|
||||
/* ----- POST confirm: Unterschrift einstempeln ----- */
|
||||
if ($action === 'confirm' && $method === 'POST') {
|
||||
if (!$user->hasRight('bericht', 'write')) api_fail('Schreibrechte fehlen', 403);
|
||||
if (empty($_FILES['file']['tmp_name'])) api_fail('file (PNG) fehlt');
|
||||
|
||||
$signer_name = trim((string) ($_POST['signer_name'] ?? ''));
|
||||
if ($signer_name === '') api_fail('signer_name fehlt');
|
||||
|
||||
$gps_lat = isset($_POST['gps_lat']) && $_POST['gps_lat'] !== '' ? (float) $_POST['gps_lat'] : null;
|
||||
$gps_lon = isset($_POST['gps_lon']) && $_POST['gps_lon'] !== '' ? (float) $_POST['gps_lon'] : null;
|
||||
|
||||
// 1) Signatur-PNG zwischenspeichern (vor ODT-Render noetig)
|
||||
require_once __DIR__.'/../class/bericht.class.php';
|
||||
$workdir = DOL_DATA_ROOT.'/bericht/work/shipment_'.((int) $shipment->id);
|
||||
if (!is_dir($workdir)) dol_mkdir($workdir);
|
||||
$sig_png = $workdir.'/signature_'.dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'.png';
|
||||
if (!move_uploaded_file($_FILES['file']['tmp_name'], $sig_png)) {
|
||||
api_fail('Signatur-Upload fehlgeschlagen', 500);
|
||||
}
|
||||
|
||||
$signed_at = date('Y-m-d H:i:s');
|
||||
$active_module = getDolGlobalString('EXPEDITION_ADDON_PDF', 'merou');
|
||||
|
||||
// Ziel-Pfad fuer signed-PDF (Expedition-ECM-Verzeichnis)
|
||||
$shipment_dir = $conf->expedition->multidir_output[$shipment->entity ?: $conf->entity].'/sending/'.dol_sanitizeFileName($shipment->ref);
|
||||
if (!is_dir($shipment_dir)) {
|
||||
$shipment_dir = $conf->expedition->multidir_output[$shipment->entity ?: $conf->entity].'/'.dol_sanitizeFileName($shipment->ref);
|
||||
}
|
||||
if (!is_dir($shipment_dir)) dol_mkdir($shipment_dir);
|
||||
$out_pdf = $shipment_dir.'/'.dol_sanitizeFileName($shipment->ref).'-signed.pdf';
|
||||
|
||||
$meta_render = array(
|
||||
'signer_name' => $signer_name,
|
||||
'signed_at' => $signed_at,
|
||||
'gps_lat' => $gps_lat,
|
||||
'gps_lon' => $gps_lon,
|
||||
'shipment' => $shipment,
|
||||
'order' => $commande,
|
||||
'kunde' => $kunde,
|
||||
);
|
||||
|
||||
$template_name = $active_module; // fuer Audit-Meta
|
||||
$ok = false;
|
||||
|
||||
if ($active_module === 'generic_shipment_odt') {
|
||||
// ODT-Pfad: Dolibarrs Standard-Pipeline nutzen (generateDocument), damit ALLE Substitutionen
|
||||
// (Kunde, Adresse, Lieferpositionen, Extrafelder, line-Iteration) sauber durchlaufen.
|
||||
// Unser actions_bericht::beforeODTSave-Hook stempelt die Unterschrift via setImage('signature').
|
||||
//
|
||||
// Wichtig: generateDocument überschreibt IMMER das Standard-PDF am gleichen Pfad.
|
||||
// Damit das normale Lieferschein-PDF (ohne Unterschrift) erhalten bleibt, sichern wir
|
||||
// es vorher und stellen es nach der signed-Kopie wieder her.
|
||||
$template_name = 'generic_shipment_odt';
|
||||
|
||||
// 1) Original-PDF sichern (falls vorhanden)
|
||||
$original_pdf = bericht_get_shipment_pdf($db, $shipment, false);
|
||||
$backup_pdf = null;
|
||||
if ($original_pdf && file_exists($original_pdf) && !preg_match('/-signed\.pdf$/i', $original_pdf)) {
|
||||
$backup_pdf = $original_pdf.'.bericht-backup';
|
||||
@copy($original_pdf, $backup_pdf);
|
||||
}
|
||||
|
||||
// 2) generateDocument mit Hook (Signatur eingestempelt)
|
||||
$shipment->bericht_signature_png = $sig_png;
|
||||
$shipment->bericht_signature_meta = array(
|
||||
'signer_name' => $signer_name,
|
||||
'signed_at' => $signed_at,
|
||||
'gps_lat' => $gps_lat,
|
||||
'gps_lon' => $gps_lon,
|
||||
);
|
||||
try {
|
||||
$rd = $shipment->generateDocument('generic_shipment_odt', $langs);
|
||||
if ($rd <= 0) api_fail('generateDocument fehlgeschlagen: '.($shipment->error ?: 'unbekannt'), 500);
|
||||
} catch (Throwable $e) {
|
||||
api_fail('Exception bei generateDocument: '.$e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// 3) Das frisch generierte (signed) PDF zu <ref>-signed.pdf kopieren
|
||||
// bericht_get_shipment_pdf mit include_signed=true findet die Standard-Datei
|
||||
// (die jetzt aber die signierte Version ist)
|
||||
$signed_in_place = bericht_get_shipment_pdf($db, $shipment, true);
|
||||
if (!$signed_in_place || !file_exists($signed_in_place)) {
|
||||
api_fail('Kein signiertes PDF nach generateDocument gefunden', 500);
|
||||
}
|
||||
if (!@copy($signed_in_place, $out_pdf)) {
|
||||
api_fail('Kopieren des signierten PDF fehlgeschlagen: '.$out_pdf, 500);
|
||||
}
|
||||
|
||||
// 4) Original wieder herstellen — Hook-Property entfernen, dann nochmal generateDocument
|
||||
// Falls Backup vorhanden, kann der schnellere Weg verwendet werden.
|
||||
unset($shipment->bericht_signature_png);
|
||||
unset($shipment->bericht_signature_meta);
|
||||
if ($backup_pdf && file_exists($backup_pdf)) {
|
||||
@copy($backup_pdf, $signed_in_place);
|
||||
@unlink($backup_pdf);
|
||||
} else {
|
||||
// Es gab kein Original-Backup → neues unsigned-PDF regenerieren
|
||||
try { $shipment->generateDocument('generic_shipment_odt', $langs); }
|
||||
catch (Throwable $e) { dol_syslog('Restaurieren unsigned PDF fehlgeschlagen: '.$e->getMessage(), LOG_WARNING); }
|
||||
}
|
||||
|
||||
$ok = true;
|
||||
} else {
|
||||
// PDF-Modul: Original-Lieferschein-PDF holen und Signatur per FPDI an konfigurierter Box-Position stempeln.
|
||||
$src_pdf = bericht_get_shipment_pdf($db, $shipment);
|
||||
if (!$src_pdf || !file_exists($src_pdf)) api_fail('Lieferschein-PDF konnte nicht erzeugt werden', 500);
|
||||
|
||||
$box = bericht_get_signature_box($db, $template_name);
|
||||
$ok = bericht_stamp_signature_on_pdf($src_pdf, $sig_png, $box, array(
|
||||
'signer_name' => $signer_name,
|
||||
'signed_at' => $signed_at,
|
||||
'gps_lat' => $gps_lat,
|
||||
'gps_lon' => $gps_lon,
|
||||
'shipment_ref' => $shipment->ref,
|
||||
), $out_pdf);
|
||||
if (!$ok) api_fail('PDF-Stempel fehlgeschlagen', 500);
|
||||
}
|
||||
|
||||
// 5) Bericht-Record (element_type='shipment') anlegen, damit wir die Signatur-Metadaten
|
||||
// in der gleichen Audit-Logik wie Berichts-Signaturen ablegen koennen
|
||||
$bericht = null;
|
||||
$existing = $db->query("SELECT rowid FROM ".$db->prefix()."bericht"
|
||||
." WHERE element_type = 'shipment' AND fk_element = ".((int) $shipment->id)
|
||||
." ORDER BY datec DESC LIMIT 1");
|
||||
if ($existing && ($row = $db->fetch_object($existing))) {
|
||||
$bericht = new Bericht($db);
|
||||
$bericht->fetch((int) $row->rowid);
|
||||
} else {
|
||||
$bericht = new Bericht($db);
|
||||
$bericht->element_type = 'shipment';
|
||||
$bericht->fk_element = (int) $shipment->id;
|
||||
$bericht->titel = 'Lieferschein '.$shipment->ref;
|
||||
$bericht->auftragsnummer = $commande ? $commande->ref : '';
|
||||
$bericht->status = Bericht::STATUS_FINAL;
|
||||
$bericht->final_pdf_path = str_replace(DOL_DATA_ROOT.'/', '', $out_pdf);
|
||||
$bericht->create($user);
|
||||
}
|
||||
if ($bericht && $bericht->id) {
|
||||
$bericht->status = Bericht::STATUS_FINAL;
|
||||
$bericht->final_pdf_path = str_replace(DOL_DATA_ROOT.'/', '', $out_pdf);
|
||||
$bericht->update($user);
|
||||
}
|
||||
|
||||
// Metadaten neben dem PNG ablegen (Audit-Spur)
|
||||
@file_put_contents($sig_png.'.meta.json', json_encode(array(
|
||||
'shipment_id' => (int) $shipment->id,
|
||||
'shipment_ref' => $shipment->ref,
|
||||
'order_ref' => $commande ? $commande->ref : null,
|
||||
'customer' => $kunde->name ?? '',
|
||||
'signer_name' => $signer_name,
|
||||
'signed_at' => $signed_at,
|
||||
'signed_at_unix' => time(),
|
||||
'user_id' => $user->id,
|
||||
'user_login' => $user->login,
|
||||
'gps_lat' => $gps_lat,
|
||||
'gps_lon' => $gps_lon,
|
||||
'template' => $template_name,
|
||||
'render_mode' => ($active_module === 'generic_shipment_odt' ? 'odt' : 'pdf_stamp'),
|
||||
'remote_ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'signed_pdf' => str_replace(DOL_DATA_ROOT.'/', '', $out_pdf),
|
||||
), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// 6) Expedition-Status: signed_status=1 + ggf. validieren+schliessen
|
||||
$status_changes = array();
|
||||
if (method_exists($shipment, 'setSignedStatus')) {
|
||||
$shipment->setSignedStatus($user, 1, 0, 'BERICHT_SIGNED');
|
||||
$status_changes[] = 'signed_status=1';
|
||||
} else {
|
||||
// Fallback: direktes SQL falls Trait fehlt
|
||||
$db->query("UPDATE ".$db->prefix()."expedition SET signed_status = 1 WHERE rowid = ".((int) $shipment->id));
|
||||
$status_changes[] = 'signed_status=1 (sql)';
|
||||
}
|
||||
|
||||
// Status-Workflow: Draft → Validated → Closed
|
||||
$current_status = (int) ($shipment->statut ?? $shipment->fk_statut ?? 0);
|
||||
if ($current_status === 0 || $current_status === Expedition::STATUS_DRAFT) {
|
||||
if (method_exists($shipment, 'valid')) {
|
||||
$rv = $shipment->valid($user);
|
||||
if ($rv > 0) $status_changes[] = 'validated';
|
||||
}
|
||||
// erneut fetchen, damit setClosed konsistente Daten sieht
|
||||
$shipment->fetch((int) $shipment->id);
|
||||
}
|
||||
if (defined('Expedition::STATUS_CLOSED') && method_exists($shipment, 'setClosed')) {
|
||||
$rc = $shipment->setClosed();
|
||||
if ($rc > 0) $status_changes[] = 'closed';
|
||||
} elseif (method_exists($shipment, 'setClosed')) {
|
||||
$shipment->setClosed();
|
||||
$status_changes[] = 'closed';
|
||||
}
|
||||
|
||||
api_ok(array(
|
||||
'bericht_id' => (int) ($bericht->id ?? 0),
|
||||
'signed_pdf' => str_replace(DOL_DATA_ROOT.'/', '', $out_pdf),
|
||||
'signed_at' => $signed_at,
|
||||
'status_changes' => $status_changes,
|
||||
));
|
||||
}
|
||||
|
||||
api_fail('Unbekannte Aktion', 400);
|
||||
77
class/actions_bericht.class.php
Normal file
77
class/actions_bericht.class.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
* GPL v3+
|
||||
*
|
||||
* Hook-Klasse fuer das Bericht-Modul.
|
||||
* Aktuell genutzt fuer: setImage('signature') beim ODT-Render von Lieferscheinen.
|
||||
*/
|
||||
|
||||
class ActionsBericht
|
||||
{
|
||||
/** @var string */
|
||||
public $error = '';
|
||||
/** @var string[] */
|
||||
public $errors = array();
|
||||
/** @var array<string,mixed> */
|
||||
public $results = array();
|
||||
|
||||
/**
|
||||
* Hook fuer Dolibarrs ODT-Generation. Wird VOR dem saveToDisk aufgerufen.
|
||||
* Wenn das Objekt eine Expedition mit gesetzter $object->bericht_signature_png ist,
|
||||
* setzen wir das Bild im ODT-Platzhalter "signature" ein und befuellen Audit-Vars.
|
||||
*
|
||||
* Wichtig (KB #434): Bei MAIN_ODT_AS_PDF=libreoffice MUSS dieser Hook (nicht afterODTCreation)
|
||||
* verwendet werden — sonst ist das ODT bereits zu PDF konvertiert.
|
||||
*
|
||||
* @param array<string,mixed> $parameters
|
||||
* @param object $object
|
||||
* @param string $action
|
||||
* @return int 0 = OK
|
||||
*/
|
||||
public function beforeODTSave($parameters, &$object, &$action)
|
||||
{
|
||||
dol_syslog('ActionsBericht::beforeODTSave aufgerufen', LOG_DEBUG);
|
||||
if (empty($parameters['odfHandler'])) { dol_syslog('ActionsBericht: kein odfHandler', LOG_DEBUG); return 0; }
|
||||
$odfHandler = $parameters['odfHandler'];
|
||||
$obj = isset($parameters['object']) ? $parameters['object'] : $object;
|
||||
if (!is_object($obj)) { dol_syslog('ActionsBericht: kein Objekt', LOG_DEBUG); return 0; }
|
||||
|
||||
$cls = get_class($obj);
|
||||
$has = property_exists($obj, 'bericht_signature_png') ? 'yes' : 'no';
|
||||
$png = $obj->bericht_signature_png ?? '';
|
||||
dol_syslog('ActionsBericht: obj='.$cls.' bericht_signature_png prop='.$has.' val='.$png.' exists='.($png && file_exists($png) ? 'yes' : 'no'), LOG_DEBUG);
|
||||
|
||||
// Nur greifen wenn shipments.php zuvor den Signatur-Pfad gesetzt hat
|
||||
if (empty($obj->bericht_signature_png) || !file_exists($obj->bericht_signature_png)) {
|
||||
dol_syslog('ActionsBericht: skip - kein signature_png', LOG_DEBUG);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// setImage: ersetzt {signature}-Text im ODT durch das PNG-draw-frame
|
||||
try {
|
||||
$ratio = (float) getDolGlobalString('BERICHT_SIGNATURE_IMAGE_RATIO', '0.35');
|
||||
$odfHandler->setImage('signature', $obj->bericht_signature_png, $ratio);
|
||||
dol_syslog('ActionsBericht: setImage(signature) ok, ratio='.$ratio.' png='.$obj->bericht_signature_png, LOG_INFO);
|
||||
} catch (Throwable $e) {
|
||||
dol_syslog('ActionsBericht::beforeODTSave setImage(signature) failed: '.$e->getMessage(), LOG_WARNING);
|
||||
}
|
||||
|
||||
// Zusatz-Variablen fuer Audit (optional im ODT als {signer_name} etc.)
|
||||
$meta = isset($obj->bericht_signature_meta) && is_array($obj->bericht_signature_meta)
|
||||
? $obj->bericht_signature_meta : array();
|
||||
$gps_str = '';
|
||||
if (!empty($meta['gps_lat']) && !empty($meta['gps_lon'])) {
|
||||
$gps_str = sprintf('%.6f, %.6f', $meta['gps_lat'], $meta['gps_lon']);
|
||||
}
|
||||
$vars = array(
|
||||
'signer_name' => (string) ($meta['signer_name'] ?? ''),
|
||||
'signed_at' => (string) ($meta['signed_at'] ?? ''),
|
||||
'gps' => $gps_str,
|
||||
);
|
||||
foreach ($vars as $k => $v) {
|
||||
try { $odfHandler->setVars($k, $v, true, 'UTF-8'); }
|
||||
catch (Throwable $e) { /* Key muss nicht im ODT existieren */ }
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ class modBericht extends DolibarrModules
|
|||
'theme' => 0,
|
||||
'css' => array('/bericht/css/bericht.css'),
|
||||
'js' => array(),
|
||||
'hooks' => array(),
|
||||
'hooks' => array('data' => array('odtgeneration')),
|
||||
'moduleforexternal' => 0,
|
||||
);
|
||||
|
||||
|
|
@ -75,6 +75,12 @@ class modBericht extends DolibarrModules
|
|||
4 => array('BERICHT_BURN_ANNOTATIONS', 'chaine', '1', 'Annotationen beim Export ins PDF einbrennen', 0, 'current', 0),
|
||||
5 => array('BERICHT_LIBREOFFICE_BIN', 'chaine', '/usr/bin/libreoffice', 'Pfad zu LibreOffice für ODT→PDF Konvertierung', 0, 'current', 0),
|
||||
6 => array('BERICHT_TAB_ON_THIRDPARTY', 'chaine', '1', 'Reiter Berichte auf Kundenkarten (read-only Übersicht)', 0, 'current', 0),
|
||||
7 => array('BERICHT_TAB_ON_SHIPMENT', 'chaine', '1', 'Reiter Bericht auf Lieferungen anzeigen', 0, 'current', 0),
|
||||
8 => array('BERICHT_SIGNATURE_BOX_DEFAULT', 'chaine',
|
||||
'{"page":"last","x_mm":120,"y_mm":230,"w_mm":70,"h_mm":35,"label":"Unterschrift Kunde"}',
|
||||
'Standard-Geometrie der Unterschriftsbox auf Lieferschein-PDFs (JSON)', 0, 'current', 0),
|
||||
9 => array('BERICHT_SIGNATURE_IMAGE_RATIO', 'chaine', '0.35',
|
||||
'Groessen-Faktor fuer Signatur im ODT (0.3 ergibt ca. 6×3 cm bei 800×400 Pixel)', 0, 'current', 0),
|
||||
);
|
||||
|
||||
// Tabs werden über den Hook (actions_bericht.class.php → addMoreActionsButtons / completeTabsHead)
|
||||
|
|
@ -85,6 +91,7 @@ class modBericht extends DolibarrModules
|
|||
'order:+bericht:Bericht:bericht@bericht:$user->hasRight("bericht","read"):/custom/bericht/bericht_card.php?id=__ID__&element=order',
|
||||
'propal:+bericht:Bericht:bericht@bericht:$user->hasRight("bericht","read"):/custom/bericht/bericht_card.php?id=__ID__&element=propal',
|
||||
'thirdparty:+bericht:Berichte:bericht@bericht:$user->hasRight("bericht","read"):/custom/bericht/bericht_thirdparty.php?socid=__ID__',
|
||||
'shipping:+bericht:Bericht:bericht@bericht:$user->hasRight("bericht","read"):/custom/bericht/bericht_card.php?id=__ID__&element=shipment',
|
||||
);
|
||||
|
||||
$this->dictionaries = array();
|
||||
|
|
@ -170,6 +177,21 @@ class modBericht extends DolibarrModules
|
|||
"ALTER TABLE ".$this->db->prefix()."bericht_upload_token ADD COLUMN fk_element INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE ".$this->db->prefix()."bericht_upload_token ADD COLUMN element_type VARCHAR(32) NOT NULL DEFAULT 'order'",
|
||||
"UPDATE ".$this->db->prefix()."bericht_upload_token SET fk_element = fk_bericht, element_type = 'order' WHERE fk_element = 0 AND fk_bericht > 0",
|
||||
// Phase 1.8: Lieferschein-Bestaetigung (PWA) - Signatur-Box-Geometrie pro PDF-Template
|
||||
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_signature_box ("
|
||||
."rowid INT AUTO_INCREMENT PRIMARY KEY,"
|
||||
."entity INT NOT NULL DEFAULT 1,"
|
||||
."template_name VARCHAR(64) NOT NULL,"
|
||||
."page VARCHAR(8) NOT NULL DEFAULT 'last',"
|
||||
."x_mm DECIMAL(7,2) NOT NULL,"
|
||||
."y_mm DECIMAL(7,2) NOT NULL,"
|
||||
."w_mm DECIMAL(7,2) NOT NULL,"
|
||||
."h_mm DECIMAL(7,2) NOT NULL,"
|
||||
."label VARCHAR(128) DEFAULT 'Unterschrift Kunde',"
|
||||
."fk_user_modif INT DEFAULT NULL,"
|
||||
."tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,"
|
||||
."UNIQUE KEY idx_bsb_template (entity, template_name)"
|
||||
.") ENGINE=innodb",
|
||||
// Phase 5.9: Materialliste pro Auftrag
|
||||
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material ("
|
||||
."rowid INT AUTO_INCREMENT PRIMARY KEY,"
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ BerichtSetupOptions = Optionen
|
|||
BerichtSetupTabInvoice = Reiter auf Rechnungen anzeigen
|
||||
BerichtSetupTabOrder = Reiter auf Aufträgen anzeigen
|
||||
BerichtSetupTabPropal = Reiter auf Angeboten anzeigen
|
||||
BerichtSetupTabShipment = Reiter auf Lieferungen anzeigen
|
||||
BerichtSetupSignatureRatio = Größen-Faktor Lieferschein-Unterschrift
|
||||
BerichtSetupSignatureRatioDesc = Wie groß das {signature}-Bild im gerenderten ODT-PDF wird (0.35 ≈ 7×3.5 cm; höher = größer). Wird zur Laufzeit gegen die PNG-Pixel multipliziert.
|
||||
BerichtSetupBurnAnnotations = Annotationen ins PDF einbrennen (statt als PDF-Annotations)
|
||||
BerichtSetupLibreOfficeBin = Pfad zu LibreOffice (für ODT→PDF)
|
||||
BerichtPlaceholdersTitle = Verfügbare Platzhalter in der ODT-Vorlage
|
||||
|
|
@ -63,6 +66,31 @@ BerichtErrorNoParent = Übergeordnetes Dokument nicht gefunden
|
|||
BerichtErrorNotAllowed = Keine Berechtigung
|
||||
BerichtErrorTemplateNotFound = Vorlage nicht gefunden
|
||||
BerichtErrorPdfFailed = PDF-Erstellung fehlgeschlagen
|
||||
BerichtSignatureBoxConfig = Unterschriftsfläche auf Lieferschein
|
||||
BerichtSignatureBoxConfigDesc = Lege fest, an welcher Stelle des Lieferschein-PDF die Kundenunterschrift gestempelt wird. Die Position wird pro PDF-Template gespeichert.
|
||||
BerichtOpenSignatureBoxEditor = Unterschriftsfläche konfigurieren
|
||||
BerichtSignatureBoxNoPreviewShipment = Es gibt noch keine Lieferung in Dolibarr — bitte erstmal eine Lieferung erstellen, damit eine Beispiel-PDF zum Konfigurieren angezeigt werden kann.
|
||||
BerichtPdfTemplate = PDF-Vorlage
|
||||
BerichtPdfModules = PDF-Module
|
||||
BerichtOdtModules = ODT-Module
|
||||
BerichtOdtFiles = ODT-Dateien (doctemplates/shipments)
|
||||
BerichtPreviewWithBox = Vorschau mit Unterschriftsbox
|
||||
BerichtBoxGeometry = Box-Geometrie
|
||||
BerichtBoxPage = Seite
|
||||
BerichtBoxPageFirst = Erste Seite
|
||||
BerichtBoxPageLast = Letzte Seite
|
||||
BerichtBoxLabel = Beschriftung
|
||||
BerichtBoxResetDefault = Auf Standard zurücksetzen
|
||||
BerichtShipments = Lieferungen
|
||||
BerichtShipmentSigned = Lieferung unterschrieben
|
||||
BerichtShipmentNotSigned = Noch nicht unterschrieben
|
||||
BerichtConfirmDelivery = Lieferung bestätigen lassen
|
||||
BerichtSignerName = Name des Unterzeichners
|
||||
BerichtSignatureConfirmText = Mit der Unterschrift bestätige ich den ordnungsgemäßen Erhalt der Lieferung.
|
||||
BerichtDeliveryConfirmed = Lieferung bestätigt — PDF wurde an die Expedition gehängt
|
||||
BerichtNoShipmentsForOrder = Zu diesem Auftrag gibt es noch keine Lieferungen.
|
||||
BerichtSignDelivery = Lieferung unterschreiben
|
||||
BerichtViewSignedPdf = Unterschriebenes PDF ansehen
|
||||
Permission50002101 = Berichte lesen
|
||||
Permission50002102 = Berichte erstellen und bearbeiten
|
||||
Permission50002103 = Berichte löschen
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ BerichtSetupOptions = Options
|
|||
BerichtSetupTabInvoice = Show tab on invoices
|
||||
BerichtSetupTabOrder = Show tab on orders
|
||||
BerichtSetupTabPropal = Show tab on proposals
|
||||
BerichtSetupTabShipment = Show tab on shipments
|
||||
BerichtSetupSignatureRatio = Delivery signature image ratio
|
||||
BerichtSetupSignatureRatioDesc = How large the {signature} image is in the rendered ODT PDF (0.35 ≈ 7×3.5 cm; higher = bigger). Multiplied against PNG pixels at runtime.
|
||||
BerichtSetupBurnAnnotations = Burn annotations into PDF (instead of PDF annotations)
|
||||
BerichtSetupLibreOfficeBin = LibreOffice binary path (for ODT→PDF)
|
||||
BerichtPlaceholdersTitle = Available placeholders in ODT template
|
||||
|
|
@ -63,6 +66,31 @@ BerichtErrorNoParent = Parent document not found
|
|||
BerichtErrorNotAllowed = Permission denied
|
||||
BerichtErrorTemplateNotFound = Template not found
|
||||
BerichtErrorPdfFailed = PDF generation failed
|
||||
BerichtSignatureBoxConfig = Signature area on delivery note
|
||||
BerichtSignatureBoxConfigDesc = Define where the customer signature is stamped onto the delivery-note PDF. Position is stored per PDF template.
|
||||
BerichtOpenSignatureBoxEditor = Configure signature area
|
||||
BerichtSignatureBoxNoPreviewShipment = No shipment exists yet in Dolibarr — please create one so we can show a sample PDF.
|
||||
BerichtPdfTemplate = PDF template
|
||||
BerichtPdfModules = PDF modules
|
||||
BerichtOdtModules = ODT modules
|
||||
BerichtOdtFiles = ODT files (doctemplates/shipments)
|
||||
BerichtPreviewWithBox = Preview with signature box
|
||||
BerichtBoxGeometry = Box geometry
|
||||
BerichtBoxPage = Page
|
||||
BerichtBoxPageFirst = First page
|
||||
BerichtBoxPageLast = Last page
|
||||
BerichtBoxLabel = Label
|
||||
BerichtBoxResetDefault = Reset to default
|
||||
BerichtShipments = Shipments
|
||||
BerichtShipmentSigned = Shipment signed
|
||||
BerichtShipmentNotSigned = Not yet signed
|
||||
BerichtConfirmDelivery = Have customer confirm delivery
|
||||
BerichtSignerName = Signer name
|
||||
BerichtSignatureConfirmText = With this signature I confirm proper receipt of the delivery.
|
||||
BerichtDeliveryConfirmed = Delivery confirmed — PDF attached to shipment
|
||||
BerichtNoShipmentsForOrder = This order has no shipments yet.
|
||||
BerichtSignDelivery = Sign delivery
|
||||
BerichtViewSignedPdf = View signed PDF
|
||||
Permission50002101 = Read reports
|
||||
Permission50002102 = Create and edit reports
|
||||
Permission50002103 = Delete reports
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ function bericht_fetch_parent(DoliDB $db, $element_type, $id)
|
|||
} 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;
|
||||
}
|
||||
|
|
@ -58,6 +61,397 @@ function bericht_fetch_parent(DoliDB $db, $element_type, $id)
|
|||
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).
|
||||
|
|
|
|||
4
sql/llx_bericht_signature_box.key.sql
Normal file
4
sql/llx_bericht_signature_box.key.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
-- GPL v3+
|
||||
|
||||
ALTER TABLE llx_bericht_signature_box ADD UNIQUE INDEX idx_bsb_template (entity, template_name);
|
||||
20
sql/llx_bericht_signature_box.sql
Normal file
20
sql/llx_bericht_signature_box.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
-- GPL v3+
|
||||
--
|
||||
-- Geometrie der Unterschrifts-Box auf Lieferschein-PDFs, pro PDF-Template konfigurierbar.
|
||||
-- Wenn fuer ein Template keine Zeile existiert, wird der Default aus
|
||||
-- BERICHT_SIGNATURE_BOX_DEFAULT (JSON) verwendet.
|
||||
|
||||
CREATE TABLE llx_bericht_signature_box (
|
||||
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
|
||||
entity INTEGER DEFAULT 1 NOT NULL,
|
||||
template_name VARCHAR(64) NOT NULL, -- z.B. 'merou', 'rouget', 'espadon', 'generic_shipment_odt'
|
||||
page VARCHAR(8) NOT NULL DEFAULT 'last', -- 'first' | 'last' | numerisch als String
|
||||
x_mm DECIMAL(7,2) NOT NULL,
|
||||
y_mm DECIMAL(7,2) NOT NULL,
|
||||
w_mm DECIMAL(7,2) NOT NULL,
|
||||
h_mm DECIMAL(7,2) NOT NULL,
|
||||
label VARCHAR(128) DEFAULT 'Unterschrift Kunde',
|
||||
fk_user_modif INTEGER DEFAULT NULL,
|
||||
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL
|
||||
) ENGINE=innodb;
|
||||
Loading…
Reference in a new issue