feat: Bericht-Vorlagen + Whisper-Transkription + Cron-Fix
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
Bericht-Vorlagen (Phase 5.5):
- DB: is_template, template_label in llx_bericht
- Bericht::fetchAllTemplates() und createFromTemplate()
- fetchAllForElement() blendet Vorlagen aus
- ajax/save_as_template.php erzeugt Vorlage aus aktuellem Bericht
- Desktop-Editor: '📋 Als Vorlage' Button im Action-Bereich
- Bericht-Übersicht: Vorlagen-Dropdown beim + Neu Button
- api/templates.php: GET list + POST create_from_template
Schnell-Bericht (Phase 4.a/4.i):
- api/reports.php?action=create POST-Endpoint: Titel, Format,
Orientation, ODT-Template, optional template_id
- api/odt_templates.php: Liste der Deckblatt-Vorlagen
Whisper-Transkription (Phase 5.7):
- api/transcribe.php: POST mit relpath, nutzt externen Whisper-
HTTP-Endpoint (whisper.cpp server ODER OpenAI-kompatibel)
- Konfiguration im Admin: BERICHT_WHISPER_URL/MODE/API_KEY/LANG
- Sprache default 'de'
Cron-Fix:
- BerichtUploadToken::cleanupExpired() ist jetzt Instanz-Methode
(Dolibarr ruft new Klasse($db) bei jobtype=method auf)
- Returnwert für Cron-Success/Failure
- Statische Variante als cleanupExpiredStatic() für direkte Aufrufe
- Damit läuft der tägliche Cron 'Expired Upload-Tokens bereinigen'
nicht mehr hängend
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
parent
f37445ac9c
commit
344f884a0f
11 changed files with 425 additions and 5 deletions
|
|
@ -67,6 +67,10 @@ if ($action === 'save_const') {
|
||||||
'BERICHT_TAB_ON_PROPAL' => GETPOST('tab_propal', 'int') ? '1' : '0',
|
'BERICHT_TAB_ON_PROPAL' => GETPOST('tab_propal', 'int') ? '1' : '0',
|
||||||
'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_MODE' => GETPOST('whisper_mode', 'alphanohtml'),
|
||||||
|
'BERICHT_WHISPER_API_KEY' => GETPOST('whisper_key', 'alphanohtml'),
|
||||||
|
'BERICHT_WHISPER_LANG' => GETPOST('whisper_lang', 'alphanohtml'),
|
||||||
);
|
);
|
||||||
foreach ($consts as $k => $v) {
|
foreach ($consts as $k => $v) {
|
||||||
dolibarr_set_const($db, $k, $v, 'chaine', 0, '', $conf->entity);
|
dolibarr_set_const($db, $k, $v, 'chaine', 0, '', $conf->entity);
|
||||||
|
|
@ -205,6 +209,25 @@ print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupLibreOfficeBin").'</
|
||||||
print '<input type="text" name="lobin" value="'.dol_escape_htmltag(getDolGlobalString('BERICHT_LIBREOFFICE_BIN', '/usr/bin/libreoffice')).'" size="40">';
|
print '<input type="text" name="lobin" value="'.dol_escape_htmltag(getDolGlobalString('BERICHT_LIBREOFFICE_BIN', '/usr/bin/libreoffice')).'" size="40">';
|
||||||
print '</td></tr>';
|
print '</td></tr>';
|
||||||
|
|
||||||
|
print '<tr><td colspan="2"><h4 style="margin:16px 0 4px 0;">🎙 Whisper-Transkription (Sprachnotizen → Text)</h4></td></tr>';
|
||||||
|
print '<tr class="oddeven"><td>Whisper-URL</td><td>';
|
||||||
|
print '<input type="text" name="whisper_url" value="'.dol_escape_htmltag(getDolGlobalString('BERICHT_WHISPER_URL', '')).'" size="60" placeholder="z.B. http://192.168.155.1:9000 oder https://api.openai.com">';
|
||||||
|
print '<br><span class="opacitymedium small">Leer lassen um Transkription zu deaktivieren. Bei OpenAI: https://api.openai.com</span>';
|
||||||
|
print '</td></tr>';
|
||||||
|
print '<tr class="oddeven"><td>Modus</td><td>';
|
||||||
|
$mode_val = getDolGlobalString('BERICHT_WHISPER_MODE', 'whispercpp');
|
||||||
|
print '<select name="whisper_mode">';
|
||||||
|
print '<option value="whispercpp"'.($mode_val === 'whispercpp' ? ' selected' : '').'>whisper.cpp server (/inference)</option>';
|
||||||
|
print '<option value="openai"'.($mode_val === 'openai' ? ' selected' : '').'>OpenAI-kompatibel (/v1/audio/transcriptions)</option>';
|
||||||
|
print '</select>';
|
||||||
|
print '</td></tr>';
|
||||||
|
print '<tr class="oddeven"><td>API-Key (nur OpenAI)</td><td>';
|
||||||
|
print '<input type="password" name="whisper_key" value="'.dol_escape_htmltag(getDolGlobalString('BERICHT_WHISPER_API_KEY', '')).'" size="60" placeholder="sk-... (leer für lokale whisper.cpp)">';
|
||||||
|
print '</td></tr>';
|
||||||
|
print '<tr class="oddeven"><td>Sprache</td><td>';
|
||||||
|
print '<input type="text" name="whisper_lang" value="'.dol_escape_htmltag(getDolGlobalString('BERICHT_WHISPER_LANG', 'de')).'" size="6">';
|
||||||
|
print '</td></tr>';
|
||||||
|
|
||||||
print '</table>';
|
print '</table>';
|
||||||
print '<br><button type="submit" class="butAction">'.$langs->trans("Save").'</button>';
|
print '<br><button type="submit" class="butAction">'.$langs->trans("Save").'</button>';
|
||||||
print '</form>';
|
print '</form>';
|
||||||
|
|
|
||||||
50
ajax/save_as_template.php
Normal file
50
ajax/save_as_template.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
/* Speichert den aktuellen Bericht als Vorlage.
|
||||||
|
* Macht eine Kopie mit is_template=1, Label aus POST,
|
||||||
|
* Seiten werden mit kopiert (Referenzen auf source_path bleiben).
|
||||||
|
*
|
||||||
|
* POST: berichtid, label, token
|
||||||
|
*/
|
||||||
|
require_once __DIR__.'/_inc.php';
|
||||||
|
|
||||||
|
global $db, $user;
|
||||||
|
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
|
||||||
|
|
||||||
|
$berichtid = (int) ($_POST['berichtid'] ?? 0);
|
||||||
|
$label = trim((string) ($_POST['label'] ?? ''));
|
||||||
|
if (!$berichtid) bericht_ajax_fail('berichtid fehlt');
|
||||||
|
if (empty($label)) bericht_ajax_fail('Label erforderlich');
|
||||||
|
|
||||||
|
$src = new Bericht($db);
|
||||||
|
if ($src->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
|
||||||
|
|
||||||
|
// Vorlage als neuer Bericht
|
||||||
|
$tpl = new Bericht($db);
|
||||||
|
$tpl->element_type = $src->element_type;
|
||||||
|
$tpl->fk_element = 0;
|
||||||
|
$tpl->titel = $src->titel;
|
||||||
|
$tpl->auftragsnummer = '';
|
||||||
|
$tpl->template_odt = $src->template_odt;
|
||||||
|
$tpl->page_format = $src->page_format;
|
||||||
|
$tpl->page_orientation = $src->page_orientation;
|
||||||
|
$tpl->is_template = 1;
|
||||||
|
$tpl->template_label = $label;
|
||||||
|
if ($tpl->create($user) <= 0) bericht_ajax_fail('Erstellen fehlgeschlagen', 500);
|
||||||
|
|
||||||
|
// Seiten kopieren (mit source_path-Referenz — die Vorlagen zeigen auf die Quell-Assets)
|
||||||
|
$src_pages = BerichtPage::fetchAllForBericht($db, $src->id);
|
||||||
|
foreach ($src_pages as $order => $p) {
|
||||||
|
$np = new BerichtPage($db);
|
||||||
|
$np->fk_bericht = $tpl->id;
|
||||||
|
$np->page_order = $order + 1;
|
||||||
|
$np->source_type = $p->source_type;
|
||||||
|
$np->source_path = $p->source_path;
|
||||||
|
$np->source_page = $p->source_page;
|
||||||
|
$np->note = $p->note;
|
||||||
|
$np->layout = $p->layout;
|
||||||
|
$np->image_scale = $p->image_scale;
|
||||||
|
$np->image_align = $p->image_align;
|
||||||
|
$np->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
bericht_ajax_ok(array('template_id' => $tpl->id, 'label' => $label));
|
||||||
16
api/odt_templates.php
Normal file
16
api/odt_templates.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
/* GET /api/odt_templates.php — Liste der ODT-Deckblatt-Templates */
|
||||||
|
require_once __DIR__.'/_inc.php';
|
||||||
|
|
||||||
|
api_authenticate();
|
||||||
|
|
||||||
|
$list = bericht_list_templates();
|
||||||
|
$out = array();
|
||||||
|
foreach ($list as $fn) {
|
||||||
|
$out[] = array(
|
||||||
|
'filename' => $fn,
|
||||||
|
'label' => preg_replace('/\.odt$/i', '', $fn),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$default = getDolGlobalString('BERICHT_DEFAULT_TEMPLATE', '');
|
||||||
|
api_ok(array('templates' => $out, 'default' => $default));
|
||||||
|
|
@ -57,6 +57,57 @@ if (!$id && $action === '') {
|
||||||
api_ok(array('reports' => $items, 'count' => count($items)));
|
api_ok(array('reports' => $items, 'count' => count($items)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----- POST: neuen Bericht anlegen ----- */
|
||||||
|
if (!$id && $_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'create') {
|
||||||
|
if (!$user->hasRight('bericht', 'write')) api_fail('Permission denied', 403);
|
||||||
|
$in = api_input();
|
||||||
|
$el_type = (string) ($in['element_type'] ?? 'order');
|
||||||
|
$el_id = (int) ($in['element_id'] ?? 0);
|
||||||
|
$titel = trim((string) ($in['titel'] ?? ''));
|
||||||
|
$format = (string) ($in['page_format'] ?? 'A4');
|
||||||
|
$orient = (string) ($in['page_orientation'] ?? 'P');
|
||||||
|
$template_id = (int) ($in['template_id'] ?? 0);
|
||||||
|
$template_odt = (string) ($in['template_odt'] ?? '');
|
||||||
|
|
||||||
|
if (!$el_id) api_fail('element_id erforderlich');
|
||||||
|
if (!in_array($el_type, array('order', 'invoice', 'propal'), true)) api_fail('element_type ungültig');
|
||||||
|
|
||||||
|
// Auftragsnummer ermitteln
|
||||||
|
$parent = bericht_fetch_parent($db, $el_type, $el_id);
|
||||||
|
if (!$parent) api_fail('Parent nicht gefunden', 404);
|
||||||
|
$auftragsnummer = '';
|
||||||
|
if ($el_type === 'invoice') {
|
||||||
|
$auftragsnummer = $parent->array_options['options_auftragsnummer'] ?? $parent->ref_client ?? $parent->ref;
|
||||||
|
} else {
|
||||||
|
$auftragsnummer = $parent->ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($template_id > 0) {
|
||||||
|
$new_id = Bericht::createFromTemplate($db, $user, $template_id, $el_type, $el_id, $auftragsnummer);
|
||||||
|
if (!$new_id) api_fail('Vorlage konnte nicht angewendet werden', 500);
|
||||||
|
// Nachbearbeiten falls abweichende Meta
|
||||||
|
$b = new Bericht($db);
|
||||||
|
$b->fetch($new_id);
|
||||||
|
if ($titel) $b->titel = $titel;
|
||||||
|
if (in_array($format, array('A4','A3','A5','Letter'), true)) $b->page_format = $format;
|
||||||
|
if (in_array($orient, array('P','L'), true)) $b->page_orientation = $orient;
|
||||||
|
if ($template_odt) $b->template_odt = $template_odt;
|
||||||
|
$b->update($user);
|
||||||
|
api_ok(array('bericht_id' => $new_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$b = new Bericht($db);
|
||||||
|
$b->element_type = $el_type;
|
||||||
|
$b->fk_element = $el_id;
|
||||||
|
$b->titel = $titel ?: ('Bericht '.$auftragsnummer);
|
||||||
|
$b->auftragsnummer = $auftragsnummer;
|
||||||
|
$b->template_odt = $template_odt ?: getDolGlobalString('BERICHT_DEFAULT_TEMPLATE', '');
|
||||||
|
$b->page_format = in_array($format, array('A4','A3','A5','Letter'), true) ? $format : 'A4';
|
||||||
|
$b->page_orientation = in_array($orient, array('P','L'), true) ? $orient : 'P';
|
||||||
|
if ($b->create($user) <= 0) api_fail('Anlegen fehlgeschlagen', 500);
|
||||||
|
api_ok(array('bericht_id' => $b->id));
|
||||||
|
}
|
||||||
|
|
||||||
if (!$id) api_fail('id erforderlich');
|
if (!$id) api_fail('id erforderlich');
|
||||||
|
|
||||||
$bericht = new Bericht($db);
|
$bericht = new Bericht($db);
|
||||||
|
|
|
||||||
61
api/templates.php
Normal file
61
api/templates.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
/* GET /api/templates.php — Liste aller Bericht-Vorlagen
|
||||||
|
* POST /api/templates.php?action=create_from_template
|
||||||
|
* Body: { template_id, element_type, element_id, auftragsnummer }
|
||||||
|
* → legt einen neuen Bericht aus einer Vorlage an, gibt bericht_id zurück
|
||||||
|
*/
|
||||||
|
require_once __DIR__.'/_inc.php';
|
||||||
|
|
||||||
|
api_authenticate();
|
||||||
|
global $db, $user;
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
if ($method === 'GET' && !$action) {
|
||||||
|
$items = array();
|
||||||
|
foreach (Bericht::fetchAllTemplates($db) as $t) {
|
||||||
|
$pages = BerichtPage::fetchAllForBericht($db, $t->id);
|
||||||
|
$items[] = array(
|
||||||
|
'id' => (int) $t->id,
|
||||||
|
'label' => $t->template_label ?: $t->titel ?: $t->ref,
|
||||||
|
'titel' => $t->titel,
|
||||||
|
'page_format' => $t->page_format,
|
||||||
|
'page_orientation' => $t->page_orientation,
|
||||||
|
'template_odt' => $t->template_odt,
|
||||||
|
'page_count' => count($pages),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
api_ok(array('templates' => $items, 'count' => count($items)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST' && $action === 'create_from_template') {
|
||||||
|
if (!$user->hasRight('bericht', 'write')) api_fail('Permission denied', 403);
|
||||||
|
$in = api_input();
|
||||||
|
$tpl_id = (int) ($in['template_id'] ?? 0);
|
||||||
|
$el_type = (string) ($in['element_type'] ?? 'order');
|
||||||
|
$el_id = (int) ($in['element_id'] ?? 0);
|
||||||
|
$auftrag = (string) ($in['auftragsnummer'] ?? '');
|
||||||
|
|
||||||
|
if (!$tpl_id || !$el_id) api_fail('template_id + element_id erforderlich');
|
||||||
|
if (!in_array($el_type, array('order', 'invoice', 'propal'), true)) api_fail('element_type ungültig');
|
||||||
|
|
||||||
|
// Auftragsnummer auto-füllen wenn leer
|
||||||
|
if (empty($auftrag)) {
|
||||||
|
$parent = bericht_fetch_parent($db, $el_type, $el_id);
|
||||||
|
if ($parent) {
|
||||||
|
if ($el_type === 'invoice') {
|
||||||
|
$auftrag = $parent->array_options['options_auftragsnummer'] ?? $parent->ref_client ?? $parent->ref;
|
||||||
|
} else {
|
||||||
|
$auftrag = $parent->ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$new_id = Bericht::createFromTemplate($db, $user, $tpl_id, $el_type, $el_id, $auftrag);
|
||||||
|
if (!$new_id) api_fail('Erstellung fehlgeschlagen', 500);
|
||||||
|
|
||||||
|
api_ok(array('bericht_id' => $new_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
api_fail('Methode/Action nicht unterstützt', 405);
|
||||||
92
api/transcribe.php
Normal file
92
api/transcribe.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
/* POST /api/transcribe.php
|
||||||
|
* Body: { relpath: "commande/SO.../voice_xxx.webm" }
|
||||||
|
* → Lädt die Audio-Datei zu einem Whisper-HTTP-Endpoint und gibt den Text zurück.
|
||||||
|
*
|
||||||
|
* Der Whisper-Endpoint wird über die Konstante BERICHT_WHISPER_URL konfiguriert.
|
||||||
|
* Default: leer → Feature deaktiviert, liefert Hinweis.
|
||||||
|
*
|
||||||
|
* Unterstützt gängige Whisper-HTTP-APIs:
|
||||||
|
* - whisper.cpp server (POST /inference mit file= field)
|
||||||
|
* - OpenAI-kompatibel (POST /v1/audio/transcriptions mit file + model=whisper-1)
|
||||||
|
* - faster-whisper-server (POST /v1/audio/transcriptions)
|
||||||
|
*
|
||||||
|
* Auswahl per BERICHT_WHISPER_MODE: 'whispercpp' (default) | 'openai'
|
||||||
|
*/
|
||||||
|
require_once __DIR__.'/_inc.php';
|
||||||
|
|
||||||
|
api_authenticate();
|
||||||
|
global $db, $user;
|
||||||
|
|
||||||
|
$in = api_input();
|
||||||
|
$relpath = (string) ($in['relpath'] ?? $_POST['relpath'] ?? '');
|
||||||
|
if (empty($relpath)) api_fail('relpath fehlt');
|
||||||
|
|
||||||
|
if (!preg_match('#^(commande|facture|propal|bericht)/#', $relpath)) {
|
||||||
|
api_fail('Pfad nicht erlaubt', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$full = bericht_resolve_data_path($relpath);
|
||||||
|
if (!$full || !file_exists($full)) api_fail('Datei nicht gefunden', 404);
|
||||||
|
|
||||||
|
$whisper_url = getDolGlobalString('BERICHT_WHISPER_URL', '');
|
||||||
|
$mode = getDolGlobalString('BERICHT_WHISPER_MODE', 'whispercpp');
|
||||||
|
$api_key = getDolGlobalString('BERICHT_WHISPER_API_KEY', '');
|
||||||
|
$language = getDolGlobalString('BERICHT_WHISPER_LANG', 'de');
|
||||||
|
|
||||||
|
if (empty($whisper_url)) {
|
||||||
|
api_fail('Whisper ist nicht konfiguriert. Admin muss BERICHT_WHISPER_URL setzen.', 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei-MIME ermitteln
|
||||||
|
$mime = function_exists('mime_content_type') ? mime_content_type($full) : 'audio/webm';
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
$cfile = new CURLFile($full, $mime, basename($full));
|
||||||
|
|
||||||
|
if ($mode === 'openai') {
|
||||||
|
curl_setopt_array($ch, array(
|
||||||
|
CURLOPT_URL => rtrim($whisper_url, '/').'/v1/audio/transcriptions',
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => array(
|
||||||
|
'file' => $cfile,
|
||||||
|
'model' => 'whisper-1',
|
||||||
|
'language' => $language,
|
||||||
|
),
|
||||||
|
CURLOPT_HTTPHEADER => $api_key ? array('Authorization: Bearer '.$api_key) : array(),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// whisper.cpp server /inference
|
||||||
|
curl_setopt_array($ch, array(
|
||||||
|
CURLOPT_URL => rtrim($whisper_url, '/').'/inference',
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => array(
|
||||||
|
'file' => $cfile,
|
||||||
|
'language' => $language,
|
||||||
|
'response_format' => 'json',
|
||||||
|
),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($err) api_fail('Whisper-Fehler: '.$err, 502);
|
||||||
|
if ($status !== 200) api_fail('Whisper HTTP '.$status.': '.substr($resp, 0, 200), 502);
|
||||||
|
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
$text = '';
|
||||||
|
if (is_array($data)) {
|
||||||
|
$text = $data['text'] ?? ($data['transcription'] ?? '');
|
||||||
|
} else {
|
||||||
|
$text = (string) $resp;
|
||||||
|
}
|
||||||
|
$text = trim($text);
|
||||||
|
|
||||||
|
api_ok(array('text' => $text, 'language' => $language));
|
||||||
|
|
@ -79,8 +79,17 @@ if ($action === 'unlink' && $berichtid > 0 && $user->hasRight('bericht', 'write'
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aktion: neuen Bericht anlegen
|
// Aktion: neuen Bericht anlegen (ggf. aus Vorlage)
|
||||||
if ($action === 'create' && $user->hasRight('bericht', 'write')) {
|
if ($action === 'create' && $user->hasRight('bericht', 'write')) {
|
||||||
|
$tpl_id = GETPOSTINT('template_id');
|
||||||
|
if ($tpl_id > 0) {
|
||||||
|
$new_id = Bericht::createFromTemplate($db, $user, $tpl_id, $element, $parent->id, $auftragsnummer);
|
||||||
|
if ($new_id) {
|
||||||
|
header("Location: ".$_SERVER['PHP_SELF'].'?berichtid='.$new_id);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
setEventMessages('Vorlage konnte nicht angewendet werden', null, 'errors');
|
||||||
|
}
|
||||||
$b = new Bericht($db);
|
$b = new Bericht($db);
|
||||||
$b->element_type = $element;
|
$b->element_type = $element;
|
||||||
$b->fk_element = $parent->id;
|
$b->fk_element = $parent->id;
|
||||||
|
|
@ -168,11 +177,20 @@ if (!$bericht) {
|
||||||
print '<div class="bericht-overview-header">';
|
print '<div class="bericht-overview-header">';
|
||||||
print '<h3>'.$langs->trans("Berichte").'</h3>';
|
print '<h3>'.$langs->trans("Berichte").'</h3>';
|
||||||
if ($user->hasRight('bericht', 'write')) {
|
if ($user->hasRight('bericht', 'write')) {
|
||||||
print '<form method="post" class="inline-block">';
|
$templates = Bericht::fetchAllTemplates($db);
|
||||||
|
print '<form method="post" class="inline-block" style="display:flex;gap:6px;align-items:center;">';
|
||||||
print '<input type="hidden" name="token" value="'.newToken().'">';
|
print '<input type="hidden" name="token" value="'.newToken().'">';
|
||||||
print '<input type="hidden" name="action" value="create">';
|
print '<input type="hidden" name="action" value="create">';
|
||||||
print '<input type="hidden" name="id" value="'.$parent->id.'">';
|
print '<input type="hidden" name="id" value="'.$parent->id.'">';
|
||||||
print '<input type="hidden" name="element" value="'.$element.'">';
|
print '<input type="hidden" name="element" value="'.$element.'">';
|
||||||
|
if (!empty($templates)) {
|
||||||
|
print '<select name="template_id" title="Vorlage verwenden (optional)">';
|
||||||
|
print '<option value="0">— Leerer Bericht —</option>';
|
||||||
|
foreach ($templates as $t) {
|
||||||
|
print '<option value="'.$t->id.'">📋 '.dol_escape_htmltag($t->template_label ?: $t->titel ?: $t->ref).'</option>';
|
||||||
|
}
|
||||||
|
print '</select>';
|
||||||
|
}
|
||||||
print '<button type="submit" class="butAction">+ '.$langs->trans("BerichtNew").'</button>';
|
print '<button type="submit" class="butAction">+ '.$langs->trans("BerichtNew").'</button>';
|
||||||
print '</form>';
|
print '</form>';
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +286,7 @@ if (!$bericht) {
|
||||||
'create_upload_token' => dol_buildpath('/bericht/ajax/create_upload_token.php', 1),
|
'create_upload_token' => dol_buildpath('/bericht/ajax/create_upload_token.php', 1),
|
||||||
'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1),
|
'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1),
|
||||||
'verify_signature' => dol_buildpath('/bericht/ajax/verify_signature.php', 1),
|
'verify_signature' => dol_buildpath('/bericht/ajax/verify_signature.php', 1),
|
||||||
|
'save_as_template' => dol_buildpath('/bericht/ajax/save_as_template.php', 1),
|
||||||
),
|
),
|
||||||
'lang' => array(
|
'lang' => array(
|
||||||
'undo' => $langs->trans("BerichtUndo"),
|
'undo' => $langs->trans("BerichtUndo"),
|
||||||
|
|
@ -489,6 +508,7 @@ if (!$bericht) {
|
||||||
print '<div class="bericht-actions">';
|
print '<div class="bericht-actions">';
|
||||||
print '<button type="button" id="btn-save-draft" class="butAction" title="Aktuellen Stand als Entwurf speichern">💾 '.$langs->trans("BerichtSaveDraft").'</button>';
|
print '<button type="button" id="btn-save-draft" class="butAction" title="Aktuellen Stand als Entwurf speichern">💾 '.$langs->trans("BerichtSaveDraft").'</button>';
|
||||||
print '<button type="button" id="btn-preview" class="butAction" title="PDF-Vorschau ansehen ohne zu finalisieren">👁️ Vorschau</button>';
|
print '<button type="button" id="btn-preview" class="butAction" title="PDF-Vorschau ansehen ohne zu finalisieren">👁️ Vorschau</button>';
|
||||||
|
print '<button type="button" id="btn-save-as-template" class="butAction" title="Aktuellen Bericht als wiederverwendbare Vorlage speichern">📋 Als Vorlage</button>';
|
||||||
print '<button type="button" id="btn-finalize" class="butActionConfirm" title="PDF erzeugen und unter Verknüpfte Dokumente ablegen">📑 '.$langs->trans("BerichtFinalize").'</button>';
|
print '<button type="button" id="btn-finalize" class="butActionConfirm" title="PDF erzeugen und unter Verknüpfte Dokumente ablegen">📑 '.$langs->trans("BerichtFinalize").'</button>';
|
||||||
if ($user->hasRight('bericht', 'delete')) {
|
if ($user->hasRight('bericht', 'delete')) {
|
||||||
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&berichtid='.$bericht->id.'&token='.newToken().'" '
|
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&berichtid='.$bericht->id.'&token='.newToken().'" '
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ class Bericht extends CommonObject
|
||||||
public $template_odt;
|
public $template_odt;
|
||||||
public $page_format = 'A4'; // A4, A3, Letter
|
public $page_format = 'A4'; // A4, A3, Letter
|
||||||
public $page_orientation = 'P'; // P=Portrait, L=Landscape
|
public $page_orientation = 'P'; // P=Portrait, L=Landscape
|
||||||
|
public $is_template = 0; // 1 = Vorlage, wird nicht als regulärer Bericht gelistet
|
||||||
|
public $template_label; // Optional Label für die Vorlage
|
||||||
public $status; // 0 = Entwurf, 1 = Final
|
public $status; // 0 = Entwurf, 1 = Final
|
||||||
public $final_pdf_path;
|
public $final_pdf_path;
|
||||||
public $fk_user_creat;
|
public $fk_user_creat;
|
||||||
|
|
@ -56,7 +58,7 @@ class Bericht extends CommonObject
|
||||||
}
|
}
|
||||||
|
|
||||||
$sql = "INSERT INTO ".$this->db->prefix()."bericht ("
|
$sql = "INSERT INTO ".$this->db->prefix()."bericht ("
|
||||||
."entity, ref, titel, element_type, fk_element, auftragsnummer, template_odt, page_format, page_orientation, status, fk_user_creat, datec, note"
|
."entity, ref, titel, element_type, fk_element, auftragsnummer, template_odt, page_format, page_orientation, is_template, template_label, status, fk_user_creat, datec, note"
|
||||||
.") VALUES ("
|
.") VALUES ("
|
||||||
.((int) $this->entity).","
|
.((int) $this->entity).","
|
||||||
."'".$this->db->escape($this->ref)."',"
|
."'".$this->db->escape($this->ref)."',"
|
||||||
|
|
@ -67,6 +69,8 @@ class Bericht extends CommonObject
|
||||||
.($this->template_odt ? "'".$this->db->escape($this->template_odt)."'" : "NULL").","
|
.($this->template_odt ? "'".$this->db->escape($this->template_odt)."'" : "NULL").","
|
||||||
."'".$this->db->escape($this->page_format)."',"
|
."'".$this->db->escape($this->page_format)."',"
|
||||||
."'".$this->db->escape($this->page_orientation)."',"
|
."'".$this->db->escape($this->page_orientation)."',"
|
||||||
|
.((int) ($this->is_template ? 1 : 0)).","
|
||||||
|
.($this->template_label ? "'".$this->db->escape($this->template_label)."'" : "NULL").","
|
||||||
.((int) $this->status).","
|
.((int) $this->status).","
|
||||||
.((int) $this->fk_user_creat).","
|
.((int) $this->fk_user_creat).","
|
||||||
."'".$this->db->idate($this->datec)."',"
|
."'".$this->db->idate($this->datec)."',"
|
||||||
|
|
@ -88,7 +92,7 @@ class Bericht extends CommonObject
|
||||||
public function fetch($id)
|
public function fetch($id)
|
||||||
{
|
{
|
||||||
$sql = "SELECT rowid, entity, ref, titel, element_type, fk_element, auftragsnummer, template_odt,"
|
$sql = "SELECT rowid, entity, ref, titel, element_type, fk_element, auftragsnummer, template_odt,"
|
||||||
." page_format, page_orientation,"
|
." page_format, page_orientation, COALESCE(is_template,0) AS is_template, template_label,"
|
||||||
." status, final_pdf_path, fk_user_creat, fk_user_modif, datec, tms, note"
|
." status, final_pdf_path, fk_user_creat, fk_user_modif, datec, tms, note"
|
||||||
." FROM ".$this->db->prefix()."bericht WHERE rowid = ".((int) $id);
|
." FROM ".$this->db->prefix()."bericht WHERE rowid = ".((int) $id);
|
||||||
$res = $this->db->query($sql);
|
$res = $this->db->query($sql);
|
||||||
|
|
@ -110,6 +114,8 @@ class Bericht extends CommonObject
|
||||||
$this->template_odt = $obj->template_odt;
|
$this->template_odt = $obj->template_odt;
|
||||||
$this->page_format = $obj->page_format ?: 'A4';
|
$this->page_format = $obj->page_format ?: 'A4';
|
||||||
$this->page_orientation= $obj->page_orientation ?: 'P';
|
$this->page_orientation= $obj->page_orientation ?: 'P';
|
||||||
|
$this->is_template = (int) $obj->is_template;
|
||||||
|
$this->template_label = $obj->template_label;
|
||||||
$this->status = (int) $obj->status;
|
$this->status = (int) $obj->status;
|
||||||
$this->final_pdf_path = $obj->final_pdf_path;
|
$this->final_pdf_path = $obj->final_pdf_path;
|
||||||
$this->fk_user_creat = $obj->fk_user_creat;
|
$this->fk_user_creat = $obj->fk_user_creat;
|
||||||
|
|
@ -169,6 +175,7 @@ class Bericht extends CommonObject
|
||||||
$sql = "SELECT rowid FROM ".$db->prefix()."bericht"
|
$sql = "SELECT rowid FROM ".$db->prefix()."bericht"
|
||||||
." WHERE element_type = '".$db->escape($element_type)."'"
|
." WHERE element_type = '".$db->escape($element_type)."'"
|
||||||
." AND fk_element = ".((int) $fk_element)
|
." AND fk_element = ".((int) $fk_element)
|
||||||
|
." AND COALESCE(is_template,0) = 0"
|
||||||
." ORDER BY datec DESC";
|
." ORDER BY datec DESC";
|
||||||
$res = $db->query($sql);
|
$res = $db->query($sql);
|
||||||
if (!$res) return $list;
|
if (!$res) return $list;
|
||||||
|
|
@ -235,6 +242,67 @@ class Bericht extends CommonObject
|
||||||
return $this->db->query($sql) ? 1 : -1;
|
return $this->db->query($sql) ? 1 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle Vorlagen (is_template = 1).
|
||||||
|
* @return Bericht[]
|
||||||
|
*/
|
||||||
|
public static function fetchAllTemplates(DoliDB $db)
|
||||||
|
{
|
||||||
|
$list = array();
|
||||||
|
$sql = "SELECT rowid FROM ".$db->prefix()."bericht"
|
||||||
|
." WHERE COALESCE(is_template,0) = 1"
|
||||||
|
." ORDER BY template_label ASC, ref ASC";
|
||||||
|
$res = $db->query($sql);
|
||||||
|
if (!$res) return $list;
|
||||||
|
while ($obj = $db->fetch_object($res)) {
|
||||||
|
$b = new self($db);
|
||||||
|
if ($b->fetch($obj->rowid) > 0) $list[] = $b;
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Bericht aus einer Vorlage:
|
||||||
|
* Titel, Format, Orientation, Template-ODT werden übernommen;
|
||||||
|
* alle Vorlagen-Seiten werden in den neuen Bericht kopiert (mit Notizen,
|
||||||
|
* aber source_path wird nur übernommen wenn es ein dauerhaftes Asset ist).
|
||||||
|
*
|
||||||
|
* @return int|false Neue Bericht-ID oder false
|
||||||
|
*/
|
||||||
|
public static function createFromTemplate(DoliDB $db, User $user, $template_id, $element_type, $fk_element, $auftragsnummer)
|
||||||
|
{
|
||||||
|
$tpl = new self($db);
|
||||||
|
if ($tpl->fetch($template_id) <= 0 || !$tpl->is_template) return false;
|
||||||
|
|
||||||
|
$b = new self($db);
|
||||||
|
$b->element_type = $element_type;
|
||||||
|
$b->fk_element = $fk_element;
|
||||||
|
$b->titel = $tpl->titel ?: ('Bericht '.$auftragsnummer);
|
||||||
|
$b->auftragsnummer = $auftragsnummer;
|
||||||
|
$b->template_odt = $tpl->template_odt;
|
||||||
|
$b->page_format = $tpl->page_format;
|
||||||
|
$b->page_orientation = $tpl->page_orientation;
|
||||||
|
$b->is_template = 0;
|
||||||
|
if ($b->create($user) <= 0) return false;
|
||||||
|
|
||||||
|
// Seiten kopieren
|
||||||
|
$tpl_pages = BerichtPage::fetchAllForBericht($db, $tpl->id);
|
||||||
|
foreach ($tpl_pages as $order => $tp) {
|
||||||
|
$np = new BerichtPage($db);
|
||||||
|
$np->fk_bericht = $b->id;
|
||||||
|
$np->page_order = $order + 1;
|
||||||
|
$np->source_type = $tp->source_type;
|
||||||
|
$np->source_path = $tp->source_path; // zeigt auf die Vorlagen-Assets (werden mitgenutzt, nicht kopiert)
|
||||||
|
$np->source_page = $tp->source_page;
|
||||||
|
$np->note = $tp->note;
|
||||||
|
$np->layout = $tp->layout;
|
||||||
|
$np->image_scale = $tp->image_scale;
|
||||||
|
$np->image_align = $tp->image_align;
|
||||||
|
$np->create();
|
||||||
|
}
|
||||||
|
return (int) $b->id;
|
||||||
|
}
|
||||||
|
|
||||||
public function getLibStatut($mode = 0)
|
public function getLibStatut($mode = 0)
|
||||||
{
|
{
|
||||||
global $langs;
|
global $langs;
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,27 @@ class BerichtUploadToken
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Instanz-Methode für den Dolibarr-Cronjob.
|
||||||
* Räumt expired Tokens auf.
|
* Räumt expired Tokens auf.
|
||||||
|
*
|
||||||
|
* @return int Anzahl gelöschter Tokens (>=0), -1 bei Fehler
|
||||||
*/
|
*/
|
||||||
public static function cleanupExpired(DoliDB $db)
|
public function cleanupExpired()
|
||||||
|
{
|
||||||
|
$sql = "DELETE FROM ".$this->db->prefix()."bericht_upload_token"
|
||||||
|
." WHERE expires_at < '".$this->db->idate(dol_now())."'";
|
||||||
|
$res = $this->db->query($sql);
|
||||||
|
if (!$res) {
|
||||||
|
$this->error = $this->db->lasterror();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return $this->db->affected_rows($res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statische Variante für Direktaufrufe außerhalb des Cronjobs.
|
||||||
|
*/
|
||||||
|
public static function cleanupExpiredStatic(DoliDB $db)
|
||||||
{
|
{
|
||||||
$db->query("DELETE FROM ".$db->prefix()."bericht_upload_token"
|
$db->query("DELETE FROM ".$db->prefix()."bericht_upload_token"
|
||||||
." WHERE expires_at < '".$db->idate(dol_now())."'");
|
." WHERE expires_at < '".$db->idate(dol_now())."'");
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,9 @@ class modBericht extends DolibarrModules
|
||||||
// Phase 1.5: Bildgröße/-position
|
// Phase 1.5: Bildgröße/-position
|
||||||
"ALTER TABLE ".$this->db->prefix()."bericht_page ADD COLUMN image_scale FLOAT DEFAULT 1.0",
|
"ALTER TABLE ".$this->db->prefix()."bericht_page ADD COLUMN image_scale FLOAT DEFAULT 1.0",
|
||||||
"ALTER TABLE ".$this->db->prefix()."bericht_page ADD COLUMN image_align VARCHAR(16) DEFAULT 'fit'",
|
"ALTER TABLE ".$this->db->prefix()."bericht_page ADD COLUMN image_align VARCHAR(16) DEFAULT 'fit'",
|
||||||
|
// Phase 5.5: Bericht-Vorlagen
|
||||||
|
"ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN is_template TINYINT(1) DEFAULT 0",
|
||||||
|
"ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN template_label VARCHAR(255) DEFAULT NULL",
|
||||||
);
|
);
|
||||||
foreach ($migrations as $sql) {
|
foreach ($migrations as $sql) {
|
||||||
// Errors ignorieren — Spalten existieren ggf. schon
|
// Errors ignorieren — Spalten existieren ggf. schon
|
||||||
|
|
|
||||||
18
js/editor.js
18
js/editor.js
|
|
@ -660,6 +660,24 @@
|
||||||
if (e.key === 'Escape') closePreviewModal();
|
if (e.key === 'Escape') closePreviewModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Als Vorlage speichern
|
||||||
|
const tplBtn = document.getElementById('btn-save-as-template');
|
||||||
|
if (tplBtn) {
|
||||||
|
tplBtn.addEventListener('click', async () => {
|
||||||
|
const label = prompt('Label für die Vorlage:\n(z. B. "PV-Anlage Standard" oder "Wallbox 11kW")');
|
||||||
|
if (!label) return;
|
||||||
|
await savePageAnnotations(false);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('token', cfg.token);
|
||||||
|
fd.append('berichtid', cfg.berichtid);
|
||||||
|
fd.append('label', label);
|
||||||
|
const r = await fetch(cfg.urls.save_as_template, { method: 'POST', body: fd });
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.success) toast('✓ Vorlage "' + label + '" gespeichert');
|
||||||
|
else alert('Fehler: ' + (data.error || ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('btn-finalize').addEventListener('click', async () => {
|
document.getElementById('btn-finalize').addEventListener('click', async () => {
|
||||||
await savePageAnnotations(false);
|
await savePageAnnotations(false);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue