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
|
# 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
|
## 1.1.0 — 2026-04-08
|
||||||
|
|
||||||
### Phase 1 Bericht-Modul Erweiterungen
|
### 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_INVOICE' => GETPOST('tab_invoice', 'int') ? '1' : '0',
|
||||||
'BERICHT_TAB_ON_ORDER' => GETPOST('tab_order', '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_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_BURN_ANNOTATIONS' => GETPOST('burn', 'int') ? '1' : '0',
|
||||||
'BERICHT_LIBREOFFICE_BIN' => GETPOST('lobin', 'alphanohtml'),
|
'BERICHT_LIBREOFFICE_BIN' => GETPOST('lobin', 'alphanohtml'),
|
||||||
'BERICHT_WHISPER_URL' => GETPOST('whisper_url', '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>';
|
||||||
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
|
// Batch-Modus Link
|
||||||
print '<div class="bericht-setup-section">';
|
print '<div class="bericht-setup-section">';
|
||||||
print '<h3>📦 Batch-Modus</h3>';
|
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("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("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("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("BerichtSetupBurnAnnotations").'</td><td>'.$cb('burn', 'BERICHT_BURN_ANNOTATIONS', '1').'</td></tr>';
|
||||||
|
|
||||||
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupLibreOfficeBin").'</td><td>';
|
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));
|
||||||
12
api/_jwt.php
12
api/_jwt.php
|
|
@ -56,11 +56,19 @@ function bericht_jwt_from_request()
|
||||||
$hdr = '';
|
$hdr = '';
|
||||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||||
$hdr = $_SERVER['HTTP_AUTHORIZATION'];
|
$hdr = $_SERVER['HTTP_AUTHORIZATION'];
|
||||||
|
} elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||||
|
$hdr = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
||||||
} elseif (function_exists('apache_request_headers')) {
|
} elseif (function_exists('apache_request_headers')) {
|
||||||
$h = apache_request_headers();
|
$h = apache_request_headers();
|
||||||
if (isset($h['Authorization'])) $hdr = $h['Authorization'];
|
if (isset($h['Authorization'])) $hdr = $h['Authorization'];
|
||||||
}
|
}
|
||||||
if (!$hdr || stripos($hdr, 'bearer ') !== 0) return null;
|
$token = '';
|
||||||
$token = trim(substr($hdr, 7));
|
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);
|
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,
|
'theme' => 0,
|
||||||
'css' => array('/bericht/css/bericht.css'),
|
'css' => array('/bericht/css/bericht.css'),
|
||||||
'js' => array(),
|
'js' => array(),
|
||||||
'hooks' => array(),
|
'hooks' => array('data' => array('odtgeneration')),
|
||||||
'moduleforexternal' => 0,
|
'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),
|
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),
|
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),
|
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)
|
// 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',
|
'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',
|
'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__',
|
'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();
|
$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 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'",
|
"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",
|
"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
|
// Phase 5.9: Materialliste pro Auftrag
|
||||||
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material ("
|
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material ("
|
||||||
."rowid INT AUTO_INCREMENT PRIMARY KEY,"
|
."rowid INT AUTO_INCREMENT PRIMARY KEY,"
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ BerichtSetupOptions = Optionen
|
||||||
BerichtSetupTabInvoice = Reiter auf Rechnungen anzeigen
|
BerichtSetupTabInvoice = Reiter auf Rechnungen anzeigen
|
||||||
BerichtSetupTabOrder = Reiter auf Aufträgen anzeigen
|
BerichtSetupTabOrder = Reiter auf Aufträgen anzeigen
|
||||||
BerichtSetupTabPropal = Reiter auf Angeboten 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)
|
BerichtSetupBurnAnnotations = Annotationen ins PDF einbrennen (statt als PDF-Annotations)
|
||||||
BerichtSetupLibreOfficeBin = Pfad zu LibreOffice (für ODT→PDF)
|
BerichtSetupLibreOfficeBin = Pfad zu LibreOffice (für ODT→PDF)
|
||||||
BerichtPlaceholdersTitle = Verfügbare Platzhalter in der ODT-Vorlage
|
BerichtPlaceholdersTitle = Verfügbare Platzhalter in der ODT-Vorlage
|
||||||
|
|
@ -63,6 +66,31 @@ BerichtErrorNoParent = Übergeordnetes Dokument nicht gefunden
|
||||||
BerichtErrorNotAllowed = Keine Berechtigung
|
BerichtErrorNotAllowed = Keine Berechtigung
|
||||||
BerichtErrorTemplateNotFound = Vorlage nicht gefunden
|
BerichtErrorTemplateNotFound = Vorlage nicht gefunden
|
||||||
BerichtErrorPdfFailed = PDF-Erstellung fehlgeschlagen
|
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
|
Permission50002101 = Berichte lesen
|
||||||
Permission50002102 = Berichte erstellen und bearbeiten
|
Permission50002102 = Berichte erstellen und bearbeiten
|
||||||
Permission50002103 = Berichte löschen
|
Permission50002103 = Berichte löschen
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ BerichtSetupOptions = Options
|
||||||
BerichtSetupTabInvoice = Show tab on invoices
|
BerichtSetupTabInvoice = Show tab on invoices
|
||||||
BerichtSetupTabOrder = Show tab on orders
|
BerichtSetupTabOrder = Show tab on orders
|
||||||
BerichtSetupTabPropal = Show tab on proposals
|
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)
|
BerichtSetupBurnAnnotations = Burn annotations into PDF (instead of PDF annotations)
|
||||||
BerichtSetupLibreOfficeBin = LibreOffice binary path (for ODT→PDF)
|
BerichtSetupLibreOfficeBin = LibreOffice binary path (for ODT→PDF)
|
||||||
BerichtPlaceholdersTitle = Available placeholders in ODT template
|
BerichtPlaceholdersTitle = Available placeholders in ODT template
|
||||||
|
|
@ -63,6 +66,31 @@ BerichtErrorNoParent = Parent document not found
|
||||||
BerichtErrorNotAllowed = Permission denied
|
BerichtErrorNotAllowed = Permission denied
|
||||||
BerichtErrorTemplateNotFound = Template not found
|
BerichtErrorTemplateNotFound = Template not found
|
||||||
BerichtErrorPdfFailed = PDF generation failed
|
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
|
Permission50002101 = Read reports
|
||||||
Permission50002102 = Create and edit reports
|
Permission50002102 = Create and edit reports
|
||||||
Permission50002103 = Delete reports
|
Permission50002103 = Delete reports
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ function bericht_fetch_parent(DoliDB $db, $element_type, $id)
|
||||||
} elseif ($element_type === 'propal') {
|
} elseif ($element_type === 'propal') {
|
||||||
require_once DOL_DOCUMENT_ROOT.'/comm/propal/class/propal.class.php';
|
require_once DOL_DOCUMENT_ROOT.'/comm/propal/class/propal.class.php';
|
||||||
$o = new Propal($db);
|
$o = new Propal($db);
|
||||||
|
} elseif ($element_type === 'shipment') {
|
||||||
|
require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php';
|
||||||
|
$o = new Expedition($db);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +61,397 @@ function bericht_fetch_parent(DoliDB $db, $element_type, $id)
|
||||||
return $o;
|
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
|
* Mappt einen element_type-Code auf den Dolibarr-internen Element-Namen
|
||||||
* für das Verzeichnis der Anhänge (multidir_output).
|
* 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