Compare commits

...

2 commits

Author SHA1 Message Date
Eduard Wisch
ca2b796b36 Feature: Lieferschein-Unterschrift via ODT-Hook + PWA-Signatur-Workflow
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>
2026-05-28 06:48:42 +02:00
Eduard Wisch
e462134a17 Dokumentation: PWA-API und Phase 4 Block 1 aktualisiert
- CLAUDE.md auf Stand 2026-04-17 gebracht
- Phase 3 (PWA MVP) und Phase 4 Block 1 als  abgeschlossen dokumentiert
- Alle neuen REST-API-Endpoints aufgelistet (auth, orders, photo, pdf, pages)
- README.md: Neue PWA-Integration-Sektion mit API-Dokumentation
- Architektur erweitert um api/-Ordner mit JWT-Auth-Endpoints

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-17 17:30:20 +02:00
16 changed files with 1645 additions and 54 deletions

View file

@ -1,8 +1,8 @@
# Bericht-Modul — Projekt-Status & Architektur
## Stand 2026-04-08
## Stand 2026-04-17
Dolibarr-Custom-Modul für Arbeitsberichte mit Browser-PDF-Editor.
Dolibarr-Custom-Modul für Arbeitsberichte mit Browser-PDF-Editor + PWA-API-Layer.
## Architektur (final)
@ -86,60 +86,54 @@ Speichert: color, stroke, fontFamily, fontSize, bold, italic, zoom
---
## Phase 4 Block 1 ✅ (2026-04-09)
PWA-Usability-Features:
- 4.g Seite löschen in PWA (ajax /api/pages.php DELETE)
- 4.h Notiz pro Seite (POST /api/pages.php {note})
- 4.j PDF-Vorschau-Modal nach Finalize (api/pdf.php liefert Blob mit JWT)
- 4.b Touch-Unterschrift (openSignatureModal → api/pages.php?action=signature)
- 4.e Web Share Target API (manifest + share.html + SW-Interception in IDB)
Neue API-Endpoints:
- api/pages.php — DELETE/POST (note, rotation) + ?action=signature&bericht_id=
- api/pdf.php — liefert Final-PDF oder on-the-fly Preview, JWT via Query-Param
PWA neue Modals (alle in app.js):
- openPageActionsModal — tap auf Report-Page-Thumb, Notiz + Löschen
- openPdfModal — iframe mit Blob-URL + Download-Link
- openSignatureModal — Touch-Canvas 2:1, Clear, Save als PNG
- openHelpModal — komplette Anleitung (bereits in v4 gebaut)
Service Worker v5 (Share Target), manifest.webmanifest mit share_target.
## Phase 2 — Mobile-Vorbereitung + API-Layer (geplant)
- 2.1 Mobile-Upload-Token Tabelle + Cleanup-Cron
- 2.2 QR-Upload Lite Modal im Editor
- 2.3 API-Layer unter `bericht/api/` mit JWT-Auth
- 2.4 REST-Endpoints: /auth/login, /orders, /orders/{id}, /orders/{id}/photos, /reports, /reports/{id}/pages
JWT-Secret aus `$dolibarr_main_instance_unique_id`, 7 Tage Lifetime, Multi-Device OK (stateless).
Auftragsfilter pro User: `fk_user_author`, `fk_user_valid`, `fk_user_modif` ODER `llx_element_contact` mit "intern verantwortlich".
---
## Phase 3 — PWA MVP (geplant)
- Repo: `data-it/baustelle-pwa` (separates SvelteKit-Projekt)
## Phase 3 — PWA MVP ✅ (2026-04-17)
- Repo: `data-it/baustelle-pwa` (SvelteKit-Projekt)
- Hosting: `awl.data-it-solution.de/baustelle/` (Apache-Alias)
- Stack: SvelteKit + Workbox + idb-keyval
- MVP: Login → Auftragsliste → Detail → Foto-Aufnahme → Offline-Queue → Sync
- Features: Login → Auftragsliste → Order-Detail → Foto/Sprachnotiz/Materialliste → PDF-Viewing
- **PDF-Viewer implementiert**: PDF.js Canvas-Rendering, Datei-Typ-Unterscheidung (Bilder/Audio/PDFs/Dokumente)
- **Weitere Dokumente**: Order-Dateien aus `commande/<ref>/` als Inline-Viewer (PDF.js) oder Download
---
## Phase 4 Block 1 ✅ (2026-04-09 bis 2026-04-17)
## Phase 4 — PWA Voll (geplant)
- Sprachnotizen (MediaRecorder)
- Touch-Skizzen-Editor
- Schnell-Bericht in PWA
- Touch-Unterschrift
PWA-API-Layer + Usability-Features:
- ✅ 2.3 API-Layer unter `bericht/api/` mit JWT-Auth (Login, Orders, Photos, Reports)
- ✅ 2.4 REST-Endpoints: /auth.php, /orders, /orders/{id}, /orders/{id}/photos, /photo.php (Whitelist)
- ✅ 4.j PDF-Vorschau-Modal + Inline-Viewing (PDF.js Canvas)
- ✅ 4.g Seite löschen in PWA (DELETE /api/pages.php)
- ✅ 4.h Notiz pro Seite (POST /api/pages.php {note})
- ✅ 4.b Touch-Unterschrift (POST /api/pages.php?action=signature)
- ✅ Datei-Typ-Unterscheidung: Bilder (Modal), Audio (Player), PDFs (Canvas), Dokumente (Download)
API-Endpoints (neue):
- POST /api/auth.php — JWT-Login (username, password)
- GET /api/orders.php — Aufträge des Users (filter: q, open)
- GET /api/orders.php?id=<id> — Order-Detail
- GET /api/orders.php?id=<id>&action=photos — Order-Dateien (Bilder/Audio/PDFs/Dokumente)
- POST /api/orders.php?action=create — Neuer Order (socid, title, ref_client, date)
- GET /api/photo.php?relpath=<path> — Datei-Serving (JWT via Header oder Query-Param, Whitelist: facture|commande|propal|bericht)
- POST /api/pages.php?action=signature&bericht_id=<id> — Touch-Unterschrift
- DELETE /api/pages.php?id=<id> — Seite löschen
- POST /api/pages.php?id=<id> — Seite-Notiz / Rotation
PWA neue Komponenten (app.js):
- openFileViewer() — Generischer Dateibetrachter (Bilder/PDFs/Audio/Download)
- openPdfViewer() — PDF.js Canvas mit Zoom/Seitennavigation/Download
- docIconFor(), formatFileSize(), formatShortDate() — Hilfsfunktionen
- Unterschrift-Modal, Notiz-Modal, Seiten-Verwaltung
Service Worker v5: Offline-Queue, Sync, Share Target API.
## Phase 5 — PWA Erweiterungen (geplant)
- Sprachnotizen-Transkription via Whisper (POST /api/transcribe.php)
- PIN-Schutz / WebAuthn
- Push-Notifications
- Web Share Target API
- Push-Notifications bei neuen Aufträgen
- Offline-Queue Optimierung (Sync bei Netzwechsel)
- QR-Code Scanner (Order-Lookup)
- Batch-Unterschriften (mehrere Orders auf einmal)
---
## Phase 5 — Optional Später
Stamps, Vorher/Nachher, Versionierung, Mess-Werkzeug, Bericht-Vorlagen, Batch-Modus, Whisper-Transkription, Offline-Map
## Phase 6 — Optional Später
Stamps, Vorher/Nachher, Versionierung, Mess-Werkzeug, Bericht-Vorlagen, Offline-Map, Geofencing
---

View file

@ -1,5 +1,49 @@
# Changelog
## 1.2.0 — 2026-05-27
### Phase 1.8 Lieferschein-Bestätigung in der PWA
**Neuer element_type='shipment'**
- Bericht-Modul unterstützt jetzt `element_type='shipment'` mit `fk_element = llx_expedition.rowid`
- Tab "Bericht" erscheint auf der Lieferung-Card (Konstante `BERICHT_TAB_ON_SHIPMENT`)
- `bericht_fetch_parent()` und `bericht_fetch_shipment_with_order()` laden Expedition + verknüpften Auftrag
**Signatur auf Lieferschein-PDF stempeln**
- Neue Tabelle `llx_bericht_signature_box`: Pro PDF-Template (merou/rouget/espadon/...) konfigurierbare Box-Geometrie (x/y/w/h in mm, Seite first/last, Label)
- Konstante `BERICHT_SIGNATURE_BOX_DEFAULT` als JSON-Default (`{"page":"last","x_mm":120,"y_mm":230,"w_mm":70,"h_mm":35,"label":"Unterschrift Kunde"}`)
- `bericht_stamp_signature_on_pdf()`: FPDI importiert Original-Lieferschein, TCPDF stempelt PNG + Name + Datum + GPS in die konfigurierte Box
**Visueller Admin-Editor (`admin/signature_box_editor.php`)**
- PDF.js rendert ein Beispiel-Lieferschein-PDF
- Fabric.js-Rechteck zum Drag&Resize der Signatur-Box auf dem Canvas
- Eingabefelder für X/Y/W/H/Label/Seite werden live synchronisiert
- "Speichern" persistiert via AJAX (`ajax/save_signature_box.php`) in `llx_bericht_signature_box` (UPSERT auf `template_name`)
- Reset-Button setzt zurück auf Default-Konstante
**Neue PWA-Route (Baustelle-App)**
- Order-Detail bekommt Button "🚚 Lieferungen"
- `#/orders/:id/shipments` zeigt Liste aller verknüpften Expeditionen mit Status- und Signed-Badge
- `#/shipments/:id` zeigt Lieferschein-PDF inline (PDF.js Canvas) + großen Button "Lieferung unterschreiben lassen"
**Vollbild-Querformat-Signatur (`openShipmentSignatureModal`)**
- `requestFullscreen()` + `screen.orientation.lock('landscape')` (Best-Effort)
- 2-Spalten-Layout: links Lieferschein-Info + Namens-Input + GPS-Toggle, rechts Vollbild-Canvas
- HiDPI-aware Canvas (`devicePixelRatio`), Pointer/Touch-Events mit quadratischer Glättung
- Beim Bestätigen: PNG → `POST /api/shipments.php?action=confirm` → Backend stempelt in PDF und legt es in `documents/expedition/<ref>/<ref>-signed.pdf`
**Auto-Workflow auf der Expedition**
- `signed_status=1` (via `CommonSignedObject::setSignedStatus`)
- Wenn Expedition noch Draft (Status 0): erst `valid()`, dann `setClosed()`
- Bericht-Record (`element_type='shipment'`) wird angelegt/aktualisiert mit `final_pdf_path` zum signierten PDF
- `<sig>.meta.json` neben der PNG mit voller Audit-Spur (signer, user, GPS, IP, Box, Template, signed_at)
**Neue API-Endpoints**
- `GET /api/shipments.php?order_id=<id>` — Liste der Lieferungen zu einem Auftrag
- `GET /api/shipments.php?id=<id>` — Detail einer Lieferung + Bericht-ID falls vorhanden
- `GET /api/shipments.php?id=<id>&action=pdf` — PDF-Stream (Original oder gestempelt)
- `POST /api/shipments.php?id=<id>&action=confirm` — Unterschrift einstempeln + Status-Workflow
## 1.1.0 — 2026-04-08
### Phase 1 Bericht-Modul Erweiterungen

View file

@ -16,6 +16,8 @@ Bilder und PDFs lassen sich im Browser annotieren (Pfeile, Kreise, Rechtecke, Te
- **Auftragsnummer** wird automatisch aus dem Extrafield `options_auftragsnummer` der Rechnung gezogen
- **Mehrere Berichte pro Dokument** möglich
- Berichte als **Entwurf** speichern (jederzeit wieder editierbar) oder **finalisieren** (PDF erzeugen)
- **PWA-API** für Mobile-Nutzung: Aufträge, Fotos, Sprachnotizen, Materiallisten, Signaturen
- **PDF-Viewer in PWA** mit PDF.js Canvas-Rendering (Zoom, Seitennummerierung, Download)
## Voraussetzungen
@ -59,6 +61,48 @@ Bilder und PDFs lassen sich im Browser annotieren (Pfeile, Kreise, Rechtecke, Te
| `{bericht_titel}` | Titel des Berichts |
| `{ersteller}` | Login-Name des erstellenden Users |
## PWA-Integration (Baustelle Mobile App)
Die **Baustelle-PWA** (`https://awl.data-it-solution.de/baustelle/`) nutzt die folgenden API-Endpoints des Bericht-Moduls:
### Authentifizierung
```
POST /custom/bericht/api/auth.php
Body: { login: string, password: string }
Response: { ok: true, token: JWT, user: {...} }
```
JWT-Token hat 7 Tage Gültigkeit und enthält: `sub` (user id), `login`, `name`, `perms` (read/write/delete/admin).
### Order-APIs
```
GET /custom/bericht/api/orders.php
GET /custom/bericht/api/orders.php?id=<id>
GET /custom/bericht/api/orders.php?id=<id>&action=photos
POST /custom/bericht/api/orders.php?action=create
```
### Dateien
```
GET /custom/bericht/api/photo.php?relpath=<path>&jwt=<token>
Liefert Dateien aus DOL_DATA_ROOT (Whitelist: facture/, commande/, propal/, bericht/)
Optional: ?size=small|mini (Thumbnails), ?download=1 (Attachment-Header)
```
### PDF-Ansicht
```
GET /custom/bericht/api/pdf.php?id=<bericht_id>&jwt=<token>
Liefert finalisiertes Bericht-PDF als Blob
```
### Seiten-Verwaltung (PWA)
```
DELETE /custom/bericht/api/pages.php?id=<page_id>
POST /custom/bericht/api/pages.php?id=<page_id> Body: { note: string }
POST /custom/bericht/api/pages.php?action=signature&bericht_id=<id>
Body: FormData mit file=<PNG-Blob>, signer_name, gps_lat, gps_lon
```
## Architektur
```
@ -68,7 +112,7 @@ bericht/
├── lib/bericht.lib.php Helper (Anhänge sammeln, Auftragsnr., Templates)
├── bericht_card.php Editor-Seite (Tab-Inhalt)
├── admin/setup.php Admin: ODT-Templates, Konstanten
├── ajax/ Endpoints (Token-geschützt)
├── ajax/ Legacy Endpoints (Token-geschützt)
│ ├── _inc.php Gemeinsamer Header
│ ├── add_attachment.php Anhang als Seite hinzufügen
│ ├── upload_extra.php Direkter Upload
@ -78,6 +122,19 @@ bericht/
│ ├── delete_page.php
│ ├── reorder_pages.php
│ └── generate_pdf.php Finalisierung: TCPDF + FPDI + ODT-Deckblatt
├── api/ REST-API (JWT-Auth)
│ ├── _inc.php JWT-Authentifizierung + Dolibarr-Init
│ ├── _jwt.php JWT encoding/decoding
│ ├── auth.php Login-Endpoint
│ ├── orders.php Order-Liste, Detail, Fotos, Create
│ ├── photo.php Datei-Serving mit Whitelist
│ ├── pdf.php Finalized Bericht-PDF
│ ├── pages.php Seiten-Verwaltung (Note, Rotation, Signature)
│ ├── reports.php Bericht-CRUD
│ ├── templates.php ODT-Templates
│ ├── materials.php Materiallisten
│ ├── voice.php Sprachnotizen (Upload + Transkription)
│ └── transcribe.php Whisper-Transkription
├── js/
│ ├── editor.js PDF.js + Fabric.js Integration
│ └── lib/ PDF.js, Fabric.js, SortableJS (lokal)

View file

@ -65,6 +65,8 @@ if ($action === 'save_const') {
'BERICHT_TAB_ON_INVOICE' => GETPOST('tab_invoice', 'int') ? '1' : '0',
'BERICHT_TAB_ON_ORDER' => GETPOST('tab_order', 'int') ? '1' : '0',
'BERICHT_TAB_ON_PROPAL' => GETPOST('tab_propal', 'int') ? '1' : '0',
'BERICHT_TAB_ON_SHIPMENT' => GETPOST('tab_shipment', 'int') ? '1' : '0',
'BERICHT_SIGNATURE_IMAGE_RATIO' => str_replace(',', '.', (string) GETPOST('sig_ratio', 'alphanohtml')) ?: '0.35',
'BERICHT_BURN_ANNOTATIONS' => GETPOST('burn', 'int') ? '1' : '0',
'BERICHT_LIBREOFFICE_BIN' => GETPOST('lobin', 'alphanohtml'),
'BERICHT_WHISPER_URL' => GETPOST('whisper_url', 'alphanohtml'),
@ -105,6 +107,14 @@ print ' <p style="color:#e0e8f0;font-size:12px;margin:12px 0 0;">Mit dem Handy
print '</div>';
print '</div>';
// Lieferschein-Unterschrift Konfigurator
print '<div class="bericht-setup-section">';
print '<h3>'.$langs->trans("BerichtSignatureBoxConfig").'</h3>';
print '<p class="opacitymedium">'.$langs->trans("BerichtSignatureBoxConfigDesc").'</p>';
$editor_url = dol_buildpath('/bericht/admin/signature_box_editor.php', 1);
print '<a href="'.dol_escape_htmltag($editor_url).'" class="butAction">✍️ '.$langs->trans("BerichtOpenSignatureBoxEditor").'</a>';
print '</div>';
// Batch-Modus Link
print '<div class="bericht-setup-section">';
print '<h3>📦 Batch-Modus</h3>';
@ -211,6 +221,12 @@ $cb = function ($name, $key, $default) {
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabInvoice").'</td><td>'.$cb('tab_invoice', 'BERICHT_TAB_ON_INVOICE', '1').'</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabOrder").'</td><td>'.$cb('tab_order', 'BERICHT_TAB_ON_ORDER', '1').'</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabPropal").'</td><td>'.$cb('tab_propal', 'BERICHT_TAB_ON_PROPAL', '1').'</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabShipment").'</td><td>'.$cb('tab_shipment', 'BERICHT_TAB_ON_SHIPMENT', '1').'</td></tr>';
$sig_ratio = getDolGlobalString('BERICHT_SIGNATURE_IMAGE_RATIO', '0.35');
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupSignatureRatio").'</td><td>';
print '<input type="number" step="0.05" min="0.05" max="2" name="sig_ratio" value="'.dol_escape_htmltag($sig_ratio).'" size="6">';
print '<br><span class="opacitymedium small">'.$langs->trans("BerichtSetupSignatureRatioDesc").'</span>';
print '</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupBurnAnnotations").'</td><td>'.$cb('burn', 'BERICHT_BURN_ANNOTATIONS', '1').'</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupLibreOfficeBin").'</td><td>';

View 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>&lt;draw:frame&gt;</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();

View 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;

View 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));

View file

@ -56,11 +56,19 @@ function bericht_jwt_from_request()
$hdr = '';
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
$hdr = $_SERVER['HTTP_AUTHORIZATION'];
} elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
$hdr = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
} elseif (function_exists('apache_request_headers')) {
$h = apache_request_headers();
if (isset($h['Authorization'])) $hdr = $h['Authorization'];
}
if (!$hdr || stripos($hdr, 'bearer ') !== 0) return null;
$token = trim(substr($hdr, 7));
$token = '';
if ($hdr && stripos($hdr, 'bearer ') === 0) {
$token = trim(substr($hdr, 7));
} elseif (!empty($_GET['jwt'])) {
// Fallback: JWT als Query-Param (fuer <img>, <object>, <iframe> die keinen Authorization-Header schicken koennen)
$token = (string) $_GET['jwt'];
}
if (!$token) return null;
return bericht_jwt_decode($token);
}

344
api/shipments.php Normal file
View 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);

View 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;
}
}

View file

@ -43,7 +43,7 @@ class modBericht extends DolibarrModules
'theme' => 0,
'css' => array('/bericht/css/bericht.css'),
'js' => array(),
'hooks' => array(),
'hooks' => array('data' => array('odtgeneration')),
'moduleforexternal' => 0,
);
@ -75,6 +75,12 @@ class modBericht extends DolibarrModules
4 => array('BERICHT_BURN_ANNOTATIONS', 'chaine', '1', 'Annotationen beim Export ins PDF einbrennen', 0, 'current', 0),
5 => array('BERICHT_LIBREOFFICE_BIN', 'chaine', '/usr/bin/libreoffice', 'Pfad zu LibreOffice für ODT→PDF Konvertierung', 0, 'current', 0),
6 => array('BERICHT_TAB_ON_THIRDPARTY', 'chaine', '1', 'Reiter Berichte auf Kundenkarten (read-only Übersicht)', 0, 'current', 0),
7 => array('BERICHT_TAB_ON_SHIPMENT', 'chaine', '1', 'Reiter Bericht auf Lieferungen anzeigen', 0, 'current', 0),
8 => array('BERICHT_SIGNATURE_BOX_DEFAULT', 'chaine',
'{"page":"last","x_mm":120,"y_mm":230,"w_mm":70,"h_mm":35,"label":"Unterschrift Kunde"}',
'Standard-Geometrie der Unterschriftsbox auf Lieferschein-PDFs (JSON)', 0, 'current', 0),
9 => array('BERICHT_SIGNATURE_IMAGE_RATIO', 'chaine', '0.35',
'Groessen-Faktor fuer Signatur im ODT (0.3 ergibt ca. 6×3 cm bei 800×400 Pixel)', 0, 'current', 0),
);
// Tabs werden über den Hook (actions_bericht.class.php → addMoreActionsButtons / completeTabsHead)
@ -85,6 +91,7 @@ class modBericht extends DolibarrModules
'order:+bericht:Bericht:bericht@bericht:$user->hasRight("bericht","read"):/custom/bericht/bericht_card.php?id=__ID__&element=order',
'propal:+bericht:Bericht:bericht@bericht:$user->hasRight("bericht","read"):/custom/bericht/bericht_card.php?id=__ID__&element=propal',
'thirdparty:+bericht:Berichte:bericht@bericht:$user->hasRight("bericht","read"):/custom/bericht/bericht_thirdparty.php?socid=__ID__',
'shipping:+bericht:Bericht:bericht@bericht:$user->hasRight("bericht","read"):/custom/bericht/bericht_card.php?id=__ID__&element=shipment',
);
$this->dictionaries = array();
@ -170,6 +177,21 @@ class modBericht extends DolibarrModules
"ALTER TABLE ".$this->db->prefix()."bericht_upload_token ADD COLUMN fk_element INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE ".$this->db->prefix()."bericht_upload_token ADD COLUMN element_type VARCHAR(32) NOT NULL DEFAULT 'order'",
"UPDATE ".$this->db->prefix()."bericht_upload_token SET fk_element = fk_bericht, element_type = 'order' WHERE fk_element = 0 AND fk_bericht > 0",
// Phase 1.8: Lieferschein-Bestaetigung (PWA) - Signatur-Box-Geometrie pro PDF-Template
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_signature_box ("
."rowid INT AUTO_INCREMENT PRIMARY KEY,"
."entity INT NOT NULL DEFAULT 1,"
."template_name VARCHAR(64) NOT NULL,"
."page VARCHAR(8) NOT NULL DEFAULT 'last',"
."x_mm DECIMAL(7,2) NOT NULL,"
."y_mm DECIMAL(7,2) NOT NULL,"
."w_mm DECIMAL(7,2) NOT NULL,"
."h_mm DECIMAL(7,2) NOT NULL,"
."label VARCHAR(128) DEFAULT 'Unterschrift Kunde',"
."fk_user_modif INT DEFAULT NULL,"
."tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,"
."UNIQUE KEY idx_bsb_template (entity, template_name)"
.") ENGINE=innodb",
// Phase 5.9: Materialliste pro Auftrag
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material ("
."rowid INT AUTO_INCREMENT PRIMARY KEY,"

View file

@ -56,6 +56,9 @@ BerichtSetupOptions = Optionen
BerichtSetupTabInvoice = Reiter auf Rechnungen anzeigen
BerichtSetupTabOrder = Reiter auf Aufträgen anzeigen
BerichtSetupTabPropal = Reiter auf Angeboten anzeigen
BerichtSetupTabShipment = Reiter auf Lieferungen anzeigen
BerichtSetupSignatureRatio = Größen-Faktor Lieferschein-Unterschrift
BerichtSetupSignatureRatioDesc = Wie groß das {signature}-Bild im gerenderten ODT-PDF wird (0.35 ≈ 7×3.5 cm; höher = größer). Wird zur Laufzeit gegen die PNG-Pixel multipliziert.
BerichtSetupBurnAnnotations = Annotationen ins PDF einbrennen (statt als PDF-Annotations)
BerichtSetupLibreOfficeBin = Pfad zu LibreOffice (für ODT→PDF)
BerichtPlaceholdersTitle = Verfügbare Platzhalter in der ODT-Vorlage
@ -63,6 +66,31 @@ BerichtErrorNoParent = Übergeordnetes Dokument nicht gefunden
BerichtErrorNotAllowed = Keine Berechtigung
BerichtErrorTemplateNotFound = Vorlage nicht gefunden
BerichtErrorPdfFailed = PDF-Erstellung fehlgeschlagen
BerichtSignatureBoxConfig = Unterschriftsfläche auf Lieferschein
BerichtSignatureBoxConfigDesc = Lege fest, an welcher Stelle des Lieferschein-PDF die Kundenunterschrift gestempelt wird. Die Position wird pro PDF-Template gespeichert.
BerichtOpenSignatureBoxEditor = Unterschriftsfläche konfigurieren
BerichtSignatureBoxNoPreviewShipment = Es gibt noch keine Lieferung in Dolibarr — bitte erstmal eine Lieferung erstellen, damit eine Beispiel-PDF zum Konfigurieren angezeigt werden kann.
BerichtPdfTemplate = PDF-Vorlage
BerichtPdfModules = PDF-Module
BerichtOdtModules = ODT-Module
BerichtOdtFiles = ODT-Dateien (doctemplates/shipments)
BerichtPreviewWithBox = Vorschau mit Unterschriftsbox
BerichtBoxGeometry = Box-Geometrie
BerichtBoxPage = Seite
BerichtBoxPageFirst = Erste Seite
BerichtBoxPageLast = Letzte Seite
BerichtBoxLabel = Beschriftung
BerichtBoxResetDefault = Auf Standard zurücksetzen
BerichtShipments = Lieferungen
BerichtShipmentSigned = Lieferung unterschrieben
BerichtShipmentNotSigned = Noch nicht unterschrieben
BerichtConfirmDelivery = Lieferung bestätigen lassen
BerichtSignerName = Name des Unterzeichners
BerichtSignatureConfirmText = Mit der Unterschrift bestätige ich den ordnungsgemäßen Erhalt der Lieferung.
BerichtDeliveryConfirmed = Lieferung bestätigt — PDF wurde an die Expedition gehängt
BerichtNoShipmentsForOrder = Zu diesem Auftrag gibt es noch keine Lieferungen.
BerichtSignDelivery = Lieferung unterschreiben
BerichtViewSignedPdf = Unterschriebenes PDF ansehen
Permission50002101 = Berichte lesen
Permission50002102 = Berichte erstellen und bearbeiten
Permission50002103 = Berichte löschen

View file

@ -56,6 +56,9 @@ BerichtSetupOptions = Options
BerichtSetupTabInvoice = Show tab on invoices
BerichtSetupTabOrder = Show tab on orders
BerichtSetupTabPropal = Show tab on proposals
BerichtSetupTabShipment = Show tab on shipments
BerichtSetupSignatureRatio = Delivery signature image ratio
BerichtSetupSignatureRatioDesc = How large the {signature} image is in the rendered ODT PDF (0.35 ≈ 7×3.5 cm; higher = bigger). Multiplied against PNG pixels at runtime.
BerichtSetupBurnAnnotations = Burn annotations into PDF (instead of PDF annotations)
BerichtSetupLibreOfficeBin = LibreOffice binary path (for ODT→PDF)
BerichtPlaceholdersTitle = Available placeholders in ODT template
@ -63,6 +66,31 @@ BerichtErrorNoParent = Parent document not found
BerichtErrorNotAllowed = Permission denied
BerichtErrorTemplateNotFound = Template not found
BerichtErrorPdfFailed = PDF generation failed
BerichtSignatureBoxConfig = Signature area on delivery note
BerichtSignatureBoxConfigDesc = Define where the customer signature is stamped onto the delivery-note PDF. Position is stored per PDF template.
BerichtOpenSignatureBoxEditor = Configure signature area
BerichtSignatureBoxNoPreviewShipment = No shipment exists yet in Dolibarr — please create one so we can show a sample PDF.
BerichtPdfTemplate = PDF template
BerichtPdfModules = PDF modules
BerichtOdtModules = ODT modules
BerichtOdtFiles = ODT files (doctemplates/shipments)
BerichtPreviewWithBox = Preview with signature box
BerichtBoxGeometry = Box geometry
BerichtBoxPage = Page
BerichtBoxPageFirst = First page
BerichtBoxPageLast = Last page
BerichtBoxLabel = Label
BerichtBoxResetDefault = Reset to default
BerichtShipments = Shipments
BerichtShipmentSigned = Shipment signed
BerichtShipmentNotSigned = Not yet signed
BerichtConfirmDelivery = Have customer confirm delivery
BerichtSignerName = Signer name
BerichtSignatureConfirmText = With this signature I confirm proper receipt of the delivery.
BerichtDeliveryConfirmed = Delivery confirmed — PDF attached to shipment
BerichtNoShipmentsForOrder = This order has no shipments yet.
BerichtSignDelivery = Sign delivery
BerichtViewSignedPdf = View signed PDF
Permission50002101 = Read reports
Permission50002102 = Create and edit reports
Permission50002103 = Delete reports

View file

@ -47,6 +47,9 @@ function bericht_fetch_parent(DoliDB $db, $element_type, $id)
} elseif ($element_type === 'propal') {
require_once DOL_DOCUMENT_ROOT.'/comm/propal/class/propal.class.php';
$o = new Propal($db);
} elseif ($element_type === 'shipment') {
require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php';
$o = new Expedition($db);
} else {
return null;
}
@ -58,6 +61,397 @@ function bericht_fetch_parent(DoliDB $db, $element_type, $id)
return $o;
}
/**
* Liefert die Expedition zu einer Lieferschein-ID und holt zusaetzlich den verknuepften Auftrag (commande)
* ueber llx_element_element. Resultat enthaelt: expedition, commande (optional), thirdparty.
*
* @return array|null ['expedition'=>Expedition,'commande'=>Commande|null,'thirdparty'=>Societe|null]
*/
function bericht_fetch_shipment_with_order(DoliDB $db, $expedition_id)
{
require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php';
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
$shipment = new Expedition($db);
if ($shipment->fetch((int) $expedition_id) <= 0) return null;
$shipment->fetch_thirdparty();
if (method_exists($shipment, 'fetch_optionals')) $shipment->fetch_optionals();
// Verknuepfung commande → shipping (commande ist source, shipping ist target)
$commande = null;
$sql = "SELECT fk_source FROM ".$db->prefix()."element_element"
." WHERE sourcetype = 'commande' AND targettype = 'shipping'"
." AND fk_target = ".((int) $expedition_id)
." LIMIT 1";
$r = $db->query($sql);
if ($r && ($row = $db->fetch_object($r))) {
$cmd = new Commande($db);
if ($cmd->fetch((int) $row->fk_source) > 0) {
$cmd->fetch_thirdparty();
if (method_exists($cmd, 'fetch_optionals')) $cmd->fetch_optionals();
$commande = $cmd;
}
}
return array(
'expedition' => $shipment,
'commande' => $commande,
'thirdparty' => $shipment->thirdparty ?: ($commande ? $commande->thirdparty : null),
);
}
/**
* Sucht das aktuell auf einer Expedition abgelegte Lieferschein-PDF.
* Liefert den absoluten Pfad oder null wenn nicht vorhanden.
*
* Mit $include_signed=false (Default) wird das "-signed.pdf" (das von uns gestempelte
* Resultat) ausgeschlossen fuer die PWA-Vorschau wollen wir das Original, nicht den
* bereits signierten Lieferschein.
*
* @param bool $include_signed Wenn true, koennen -signed.pdf-Dateien als Treffer dienen
*/
function bericht_get_shipment_pdf(DoliDB $db, $shipment, $include_signed = false)
{
global $conf, $user, $langs;
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
if (empty($shipment) || empty($shipment->ref)) return null;
$ref_sane = dol_sanitizeFileName($shipment->ref);
$shipment_dir = $conf->expedition->multidir_output[$shipment->entity ?: $conf->entity].'/sending/'.$ref_sane;
if (!is_dir($shipment_dir)) {
$shipment_dir = $conf->expedition->multidir_output[$shipment->entity ?: $conf->entity].'/'.$ref_sane;
}
$is_signed = function ($path) {
return (bool) preg_match('/-signed\.pdf$/i', $path);
};
// 1) Bevorzugt <ref>.pdf direkt
$candidate = $shipment_dir.'/'.$ref_sane.'.pdf';
if (file_exists($candidate) && ($include_signed || !$is_signed($candidate))) return $candidate;
// 2) Wenn nichts da: generieren lassen
if (!is_dir($shipment_dir) || empty(glob($shipment_dir.'/*.pdf'))) {
$template = getDolGlobalString('EXPEDITION_ADDON_PDF', 'merou');
try { $shipment->generateDocument($template, $langs); }
catch (Throwable $e) { return null; }
}
// 3) PDFs im Verzeichnis filtern — signed ausschließen falls nicht erwünscht
if (is_dir($shipment_dir)) {
$pdfs = glob($shipment_dir.'/*.pdf') ?: array();
if (!$include_signed) {
$pdfs = array_values(array_filter($pdfs, function ($f) use ($is_signed) {
return !$is_signed($f);
}));
}
if (!empty($pdfs)) {
// Neueste zuerst (frisch generiert > alt)
usort($pdfs, function ($a, $b) { return filemtime($b) <=> filemtime($a); });
return $pdfs[0];
}
}
return null;
}
/**
* Liefert die Box-Geometrie fuer ein Lieferschein-Template.
* Nimmt zuerst llx_bericht_signature_box (template_name match), sonst Default-JSON-Konstante.
*
* @return array ['page'=>'first'|'last'|int, 'x_mm'=>float, 'y_mm'=>float, 'w_mm'=>float, 'h_mm'=>float, 'label'=>string]
*/
/**
* Rendert ein ODT-Lieferschein-Template mit Substitution:
* - Bild-Platzhalter "signature" (im ODT als Bild-Name gesetzt) Kunden-Unterschrifts-PNG
* - Textvariablen werden via Odf::setVars befuellt:
* {signer_name}, {signed_at}, {gps}, {kunde_name}, {kunde_adresse}, {shipment_ref}, {order_ref}
* - LibreOffice headless konvertiert ODT PDF
*
* @param string $odt_template_path absoluter Pfad zum Quell-ODT (doctemplates/shipments/*.odt)
* @param string $signature_png absoluter Pfad zum Unterschrifts-PNG
* @param array $meta Substitutions-Daten: signer_name, signed_at, gps_lat, gps_lon, shipment, order, kunde
* @param string $out_pdf absoluter Zielpfad fuer das fertige PDF
*
* @return bool true bei Erfolg
*/
function bericht_render_signed_shipment_odt($odt_template_path, $signature_png, array $meta, $out_pdf, &$debug = '')
{
if (!file_exists($odt_template_path)) { $debug = 'ODT-Template fehlt: '.$odt_template_path; return false; }
if (!file_exists($signature_png)) { $debug = 'Signatur-PNG fehlt: '.$signature_png; return false; }
$odt_loader = DOL_DOCUMENT_ROOT.'/includes/odtphp/odf.php';
if (!file_exists($odt_loader)) { $debug = 'odf.php-Loader fehlt: '.$odt_loader; return false; }
require_once $odt_loader;
$tempdir = DOL_DATA_ROOT.'/bericht/temp';
if (!is_dir($tempdir)) dol_mkdir($tempdir);
try {
$odf = new Odf($odt_template_path, array('PATH_TO_TMP' => $tempdir));
// Text-Platzhalter {signature} im ODT durch ein draw:frame mit der Kunden-Unterschrift ersetzen.
// setImage() rechnet Pixel × PIXEL_TO_CM × ratio — ratio steuert die Endgroesse im PDF.
// 1.0 = 1:1 bei 96dpi (zu gross fuer Signaturen). 0.3 ergibt ca. 6×3 cm bei 800×400 Canvas.
$ratio = (float) getDolGlobalString('BERICHT_SIGNATURE_IMAGE_RATIO', '0.35');
try {
$odf->setImage('signature', $signature_png, $ratio);
} catch (Throwable $e) {
// Wenn {signature}-Platzhalter im ODT fehlt, ist das ein Konfigurationsproblem —
// wir loggen es aber rendern trotzdem (die Textvars werden weiter ersetzt).
dol_syslog('bericht_render_signed_shipment_odt: setImage(signature) failed: '.$e->getMessage(), LOG_WARNING);
}
// Textvariablen — werden nur ersetzt wenn sie im ODT als {key} stehen, sonst ignoriert.
$shipment = $meta['shipment'] ?? null;
$order = $meta['order'] ?? null;
$kunde = $meta['kunde'] ?? null;
$gps_str = (isset($meta['gps_lat'], $meta['gps_lon']) && $meta['gps_lat'] !== null && $meta['gps_lon'] !== null)
? sprintf('%.6f, %.6f', $meta['gps_lat'], $meta['gps_lon'])
: '';
// Auftragsnummer: zuerst Extrafeld der Expedition (Eddys Computed-Field),
// dann Fallback Extrafeld der Commande, dann ref_client, dann commande->ref.
$auftragsnummer = '';
if ($shipment && !empty($shipment->array_options['options_auftragsnummer'])) {
$auftragsnummer = $shipment->array_options['options_auftragsnummer'];
} elseif ($order && !empty($order->array_options['options_auftragsnummer'])) {
$auftragsnummer = $order->array_options['options_auftragsnummer'];
} elseif ($order && !empty($order->ref_client)) {
$auftragsnummer = $order->ref_client;
} elseif ($order) {
$auftragsnummer = $order->ref;
}
$vars = array(
'signer_name' => (string) ($meta['signer_name'] ?? ''),
'signed_at' => (string) ($meta['signed_at'] ?? ''),
'gps' => $gps_str,
'shipment_ref' => $shipment ? $shipment->ref : '',
'order_ref' => $order ? $order->ref : '',
'auftragsnummer' => $auftragsnummer,
'kunde_name' => $kunde->name ?? '',
'kunde_adresse' => $kunde
? trim(($kunde->address ?? '')."\n".(($kunde->zip ?? '').' '.($kunde->town ?? '')))
: '',
'datum' => dol_print_date(dol_now(), 'day'),
);
// Alle Expedition-Extrafelder als {options_<name>} zusaetzlich bereitstellen
// (deckt Sonderfaelle ab wenn Eddy weitere Extrafelder im ODT nutzen will)
if ($shipment && !empty($shipment->array_options) && is_array($shipment->array_options)) {
foreach ($shipment->array_options as $k => $v) {
if (is_scalar($v) || $v === null) $vars[$k] = (string) ($v ?? '');
}
}
foreach ($vars as $k => $v) {
try { $odf->setVars($k, $v, true, 'UTF-8'); } catch (Throwable $e) { /* nicht alle Keys muessen im ODT existieren */ }
}
$odt_out = $tempdir.'/signed_shipment_'.uniqid().'.odt';
$odf->saveToDisk($odt_out);
if (!file_exists($odt_out)) { $debug = 'saveToDisk hat kein ODT erzeugt: '.$odt_out; return false; }
// LibreOffice headless ODT → PDF
// Pfad ermitteln: BERICHT_LIBREOFFICE_BIN oder via 'which'
$lobin = getDolGlobalString('BERICHT_LIBREOFFICE_BIN', '');
if (!$lobin || !is_executable($lobin)) {
// Try 'soffice' (Dolibarr-Standard) und 'libreoffice'
foreach (array('soffice', 'libreoffice') as $candidate) {
$found = @trim(@shell_exec('command -v '.escapeshellarg($candidate).' 2>/dev/null'));
if ($found && is_executable($found)) { $lobin = $found; break; }
}
}
if (!$lobin) { $debug = 'LibreOffice-Binary nicht gefunden. Setze BERICHT_LIBREOFFICE_BIN auf vollen Pfad zu soffice.'; return false; }
$tmp_outdir = $tempdir.'/lo_'.uniqid();
dol_mkdir($tmp_outdir);
// -env:UserInstallation noetig damit LO unter Apache-User starten kann (sonst /root/.config-Konflikte)
$user_profile = $tempdir.'/lo_profile_'.uniqid();
dol_mkdir($user_profile);
$cmd = escapeshellcmd($lobin)
.' --headless'
.' -env:UserInstallation=file://'.escapeshellarg($user_profile)
.' --convert-to pdf --outdir '.escapeshellarg($tmp_outdir)
.' '.escapeshellarg($odt_out).' 2>&1';
$lo_out = @shell_exec($cmd);
$pdf_generated = preg_replace('/\.odt$/i', '.pdf', basename($odt_out));
$pdf_full = $tmp_outdir.'/'.$pdf_generated;
$ok = false;
if (file_exists($pdf_full)) {
$out_dir = dirname($out_pdf);
if (!is_dir($out_dir)) dol_mkdir($out_dir);
$ok = @rename($pdf_full, $out_pdf);
if (!$ok) $ok = @copy($pdf_full, $out_pdf);
if (!$ok) $debug = 'rename/copy PDF nach Zielpfad fehlgeschlagen: '.$out_pdf;
} else {
$debug = 'LibreOffice hat kein PDF erzeugt. Befehl: '.$cmd.' | Output: '.substr((string) $lo_out, 0, 500);
}
// Aufraeumen
@unlink($odt_out);
if (is_dir($tmp_outdir)) {
foreach (glob($tmp_outdir.'/*') as $f) @unlink($f);
@rmdir($tmp_outdir);
}
if (is_dir($user_profile)) {
// rekursiv loeschen, LO erzeugt Subfolder
@exec('rm -rf '.escapeshellarg($user_profile));
}
return $ok && file_exists($out_pdf);
} catch (Throwable $e) {
$debug = 'Exception: '.$e->getMessage().' @ '.basename($e->getFile()).':'.$e->getLine();
dol_syslog('bericht_render_signed_shipment_odt: '.$debug, LOG_ERR);
return false;
}
}
/**
* Stempelt eine PNG-Signatur (plus Metadaten-Label) in ein vorhandenes Lieferschein-PDF.
* Nutzt FPDI zum Importieren der Originalseiten und TCPDF zum Daraufschreiben.
* Die Box-Geometrie kommt aus llx_bericht_signature_box (template_name match) oder Default.
*
* @param string $src_pdf absoluter Pfad zum Original-Lieferschein-PDF
* @param string $signature_png absoluter Pfad zum PNG der Unterschrift
* @param array $box ['page','x_mm','y_mm','w_mm','h_mm','label']
* @param array $meta ['signer_name','signed_at','gps_lat','gps_lon','shipment_ref']
* @param string $out_pdf absoluter Pfad fuer die Ausgabe
*
* @return bool true bei Erfolg
*/
function bericht_stamp_signature_on_pdf($src_pdf, $signature_png, array $box, array $meta, $out_pdf)
{
if (!file_exists($src_pdf) || !file_exists($signature_png)) return false;
// FPDI bei Bedarf laden
if (!class_exists('\\setasign\\Fpdi\\Tcpdf\\Fpdi')) {
$candidates = array(
DOL_DOCUMENT_ROOT.'/includes/setasign/vendor/autoload.php',
DOL_DOCUMENT_ROOT.'/includes/fpdi/autoload.php',
DOL_DOCUMENT_ROOT.'/includes/setasign/vendor/setasign/fpdi/src/Tcpdf/Fpdi.php',
);
foreach ($candidates as $c) {
if (file_exists($c)) { require_once $c; break; }
}
}
if (!class_exists('\\setasign\\Fpdi\\Tcpdf\\Fpdi')) return false;
try {
$pdf = new \setasign\Fpdi\Tcpdf\Fpdi();
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
$pdf->setAutoPageBreak(false, 0);
$pageCount = $pdf->setSourceFile($src_pdf);
// Ziel-Seite auswaehlen
$target_page = 1;
if (is_numeric($box['page'])) {
$target_page = max(1, min($pageCount, (int) $box['page']));
} elseif ($box['page'] === 'first') {
$target_page = 1;
} else { // 'last' oder beliebiger nicht-numerischer Wert
$target_page = $pageCount;
}
for ($p = 1; $p <= $pageCount; $p++) {
$tpl = $pdf->importPage($p);
$size = $pdf->getTemplateSize($tpl);
$orient = ($size['width'] > $size['height']) ? 'L' : 'P';
$pdf->AddPage($orient, array($size['width'], $size['height']));
$pdf->useTemplate($tpl);
if ($p === $target_page) {
$x = (float) $box['x_mm'];
$y = (float) $box['y_mm'];
$w = (float) $box['w_mm'];
$h = (float) $box['h_mm'];
$label = $box['label'] ?? 'Unterschrift Kunde';
// Rahmen der Box (zart, mehr als Hilfsmarkierung)
$pdf->SetDrawColor(120, 120, 120);
$pdf->SetLineWidth(0.15);
$pdf->Rect($x, $y, $w, $h, 'D');
// Label oberhalb der Box
$pdf->SetFont('helvetica', 'B', 8);
$pdf->SetTextColor(80, 80, 80);
$pdf->SetXY($x, max(0, $y - 4));
$pdf->Cell($w, 3.5, $label, 0, 0, 'L');
// PNG hinein, mit Padding und proportionaler Skalierung
$pad = 2;
$sigBoxW = max(1, $w - 2 * $pad);
$sigBoxH = max(1, $h - 2 * $pad - 6); // 6 mm fuer Footer-Text
list($iw, $ih) = @getimagesize($signature_png);
if ($iw && $ih) {
$ratio = min($sigBoxW / $iw, $sigBoxH / $ih);
$dw = $iw * $ratio;
$dh = $ih * $ratio;
$dx = $x + ($w - $dw) / 2;
$dy = $y + $pad;
$pdf->Image($signature_png, $dx, $dy, $dw, $dh, 'PNG');
}
// Trennlinie unter der Unterschrift
$sepY = $y + $h - 6;
$pdf->SetDrawColor(180, 180, 180);
$pdf->Line($x + $pad, $sepY, $x + $w - $pad, $sepY);
// Footer-Text: Name + Datum + GPS
$pdf->SetFont('helvetica', '', 6.5);
$pdf->SetTextColor(60, 60, 60);
$pdf->SetXY($x + $pad, $sepY + 0.5);
$line1 = trim(($meta['signer_name'] ?? '') . ($meta['signed_at'] ? ' · '.$meta['signed_at'] : ''));
$pdf->Cell($w - 2 * $pad, 3, $line1, 0, 0, 'L');
if (!empty($meta['gps_lat']) && !empty($meta['gps_lon'])) {
$pdf->SetXY($x + $pad, $sepY + 3);
$pdf->Cell($w - 2 * $pad, 3, sprintf('GPS: %.6f, %.6f', $meta['gps_lat'], $meta['gps_lon']), 0, 0, 'L');
}
}
}
$pdf->Output($out_pdf, 'F');
return file_exists($out_pdf);
} catch (Throwable $e) {
dol_syslog('bericht_stamp_signature_on_pdf: '.$e->getMessage(), LOG_ERR);
return false;
}
}
function bericht_get_signature_box(DoliDB $db, $template_name = '')
{
global $conf;
if ($template_name !== '') {
$sql = "SELECT page, x_mm, y_mm, w_mm, h_mm, label FROM ".$db->prefix()."bericht_signature_box"
." WHERE entity = ".((int) $conf->entity)
." AND template_name = '".$db->escape($template_name)."'"
." LIMIT 1";
$r = $db->query($sql);
if ($r && ($row = $db->fetch_object($r))) {
return array(
'page' => $row->page,
'x_mm' => (float) $row->x_mm,
'y_mm' => (float) $row->y_mm,
'w_mm' => (float) $row->w_mm,
'h_mm' => (float) $row->h_mm,
'label' => $row->label ?: 'Unterschrift Kunde',
);
}
}
// Default aus Konstante
$json = getDolGlobalString('BERICHT_SIGNATURE_BOX_DEFAULT', '');
$def = $json ? json_decode($json, true) : null;
if (!is_array($def)) {
$def = array('page' => 'last', 'x_mm' => 120, 'y_mm' => 230, 'w_mm' => 70, 'h_mm' => 35, 'label' => 'Unterschrift Kunde');
}
return $def;
}
/**
* Mappt einen element_type-Code auf den Dolibarr-internen Element-Namen
* für das Verzeichnis der Anhänge (multidir_output).

View 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);

View 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;