Upload via GUI (340 Dateien)

This commit is contained in:
2026-10-26 21:19:07 +00:00
parent 0df18c6747
commit ecabba060c
6 changed files with 545 additions and 19 deletions
+237 -3
View File
@@ -222,6 +222,201 @@ function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentI
];
}
function ticket_sla_is_paused_status(string $status): bool
{
return in_array($status, ['pending', 'on_hold'], true);
}
function ticket_sla_history_add(PDO $pdo, int $ticketId, string $event, ?string $deadlineType, string $details, ?int $staffId = null, ?string $at = null): void
{
try {
$pdo->prepare('INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,staff_id,created_at) VALUES (?,?,?,?,?,?)')
->execute([$ticketId, $event, $deadlineType, $details, $staffId, $at ?? db_now()]);
} catch (Throwable $e) {
error_log('DeskHub SLA-Verlauf konnte nicht gespeichert werden: ' . $e->getMessage());
}
}
/** Seconds that count against an SLA during the interval, honoring business hours and holidays. */
function ticket_sla_working_seconds_between(string $from, string $to): int
{
$start = strtotime($from); $end = strtotime($to);
if ($start === false || $end === false || $end <= $start) return 0;
if (get_setting('business_hours_enabled', '0') !== '1') return $end - $start;
$pdo = get_db(); $hours = [];
try {
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
} catch (Throwable $e) { error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage()); return $end - $start; }
$zone = new DateTimeZone(date_default_timezone_get());
$day = (new DateTimeImmutable('@' . $start))->setTimezone($zone)->setTime(0, 0);
$lastDay = (new DateTimeImmutable('@' . $end))->setTimezone($zone)->setTime(0, 0);
$seconds = 0; $iterations = 0;
while ($day <= $lastDay && $iterations++ < 36600) {
$w = (int) $day->format('w'); $cfg = $hours[$w] ?? null; $date = $day->format('Y-m-d');
if ($cfg && !empty($cfg['is_open']) && !isset($holidays[$date])) {
$open = (new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone))->getTimestamp();
$close = (new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone))->getTimestamp();
$seconds += max(0, min($end, $close) - max($start, $open));
}
$day = $day->modify('+1 day');
}
return $seconds;
}
function ticket_sla_add_business_seconds(string $from, int $seconds): string
{
if ($seconds <= 0) return $from;
$pdo = get_db(); $hours = [];
try {
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
} catch (Throwable $e) {
error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage());
return (new DateTimeImmutable($from))->modify('+' . $seconds . ' seconds')->format('Y-m-d H:i:s');
}
$zone = new DateTimeZone(date_default_timezone_get());
$cursor = new DateTimeImmutable($from, $zone); $iterations = 0;
while ($seconds > 0 && $iterations++ < 36600) {
$date = $cursor->format('Y-m-d'); $cfg = $hours[(int) $cursor->format('w')] ?? null;
if (!$cfg || empty($cfg['is_open']) || isset($holidays[$date])) {
$cursor = $cursor->modify('+1 day')->setTime(0, 0); continue;
}
$open = new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone);
$close = new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone);
if ($cursor < $open) $cursor = $open;
if ($cursor >= $close) { $cursor = $cursor->modify('+1 day')->setTime(0, 0); continue; }
$available = $close->getTimestamp() - $cursor->getTimestamp();
if ($seconds <= $available) { $cursor = $cursor->modify('+' . $seconds . ' seconds'); $seconds = 0; }
else { $seconds -= $available; $cursor = $cursor->modify('+1 day')->setTime(0, 0); }
}
return $cursor->format('Y-m-d H:i:s');
}
function ticket_sla_shift_deadline(string $deadline, int $pauseSeconds): string
{
if ($pauseSeconds <= 0) return $deadline;
if (get_setting('business_hours_enabled', '0') === '1') {
return ticket_sla_add_business_seconds($deadline, $pauseSeconds);
}
return (new DateTimeImmutable($deadline))->modify('+' . $pauseSeconds . ' seconds')->format('Y-m-d H:i:s');
}
function ticket_sla_completed_pause_seconds(PDO $pdo, int $ticketId): int
{
try {
$stmt = $pdo->prepare("SELECT event_type,created_at FROM ticket_sla_history WHERE ticket_id = ? AND event_type IN ('paused','resumed') ORDER BY created_at,id");
$stmt->execute([$ticketId]);
$started = null; $total = 0;
foreach ($stmt->fetchAll() as $event) {
if ($event['event_type'] === 'paused') $started = (string) $event['created_at'];
elseif ($started !== null) { $total += ticket_sla_working_seconds_between($started, (string) $event['created_at']); $started = null; }
}
return $total;
} catch (Throwable $e) { error_log('DeskHub SLA-Pausenzeiten nicht lesbar: ' . $e->getMessage()); return 0; }
}
/** Call after the ticket status has been persisted. pending/on_hold pause both SLA clocks. */
function ticket_sla_status_transition_impl(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
{
if ($oldStatus === $newStatus) return;
$wasPaused = ticket_sla_is_paused_status($oldStatus); $willPause = ticket_sla_is_paused_status($newStatus);
if (!$wasPaused && $willPause) {
$now = db_now();
$pdo->prepare('UPDATE tickets SET sla_paused_at = ? WHERE id = ?')->execute([$now, $ticketId]);
ticket_sla_history_add($pdo, $ticketId, 'paused', null, 'Pausiert: ' . status_label($newStatus)[0], $staffId, $now);
$q = $pdo->prepare('SELECT first_response_due_at,resolution_due_at,first_responded_at,resolved_at FROM tickets WHERE id = ?');
$q->execute([$ticketId]); $deadlines = $q->fetch() ?: [];
if (empty($deadlines['first_responded_at']) && !empty($deadlines['first_response_due_at']) && $deadlines['first_response_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $deadlines['first_response_due_at']);
}
if (empty($deadlines['resolved_at']) && !empty($deadlines['resolution_due_at']) && $deadlines['resolution_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $deadlines['resolution_due_at']);
$pdo->prepare('UPDATE tickets SET is_overdue = 1 WHERE id = ?')->execute([$ticketId]);
}
return;
}
if ($wasPaused && !$willPause) {
$stmt = $pdo->prepare('SELECT sla_paused_at,first_response_due_at,resolution_due_at,first_responded_at FROM tickets WHERE id = ?');
$stmt->execute([$ticketId]); $row = $stmt->fetch();
if (!$row) return;
$now = db_now(); $started = (string) ($row['sla_paused_at'] ?? '');
$seconds = $started !== '' ? ticket_sla_working_seconds_between($started, $now) : 0;
$respondedBeforePause = !empty($row['first_responded_at']) && ($started === '' || (string) $row['first_responded_at'] < $started);
$updates = []; $args = [];
if ($seconds > 0) {
foreach (['first_response_due_at' => 'first_response', 'resolution_due_at' => 'resolution'] as $field => $deadlineType) {
$breach = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id=? AND event_type='overdue' AND deadline_type=?");
$breach->execute([$ticketId, $deadlineType]);
if (!empty($row[$field]) && !($deadlineType === 'first_response' && $respondedBeforePause) && !(int) $breach->fetchColumn()) {
$updates[] = "$field = ?"; $args[] = ticket_sla_shift_deadline((string) $row[$field], $seconds);
}
}
}
$updates[] = 'sla_paused_at = NULL'; $args[] = $ticketId;
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($args);
$minutes = (int) floor($seconds / 60);
ticket_sla_history_add($pdo, $ticketId, 'resumed', null, 'Fortgesetzt nach ' . $minutes . ' Minuten Wartezeit', $staffId, $now);
}
}
function ticket_sla_status_transition(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
{
try {
ticket_sla_status_transition_impl($pdo, $ticketId, $oldStatus, $newStatus, $staffId);
} catch (Throwable $e) {
error_log('DeskHub SLA-Pause konnte für Ticket #' . $ticketId . ' nicht aktualisiert werden: ' . $e->getMessage());
}
}
function ticket_sla_transition_rows(PDO $pdo, array $rows, string $newStatus, ?int $staffId = null): void
{
foreach ($rows as $row) ticket_sla_status_transition($pdo, (int) $row['id'], (string) $row['status'], $newStatus, $staffId);
}
function ticket_sla_history_for_ticket(PDO $pdo, array $ticket): array
{
try {
$ticketId = (int) $ticket['id'];
$currentlyPaused = !empty($ticket['sla_paused_at']) || ticket_sla_is_paused_status((string) ($ticket['status'] ?? ''));
if (!$currentlyPaused && !in_array((string) ($ticket['status'] ?? ''), ['resolved', 'closed'], true)) {
$now = db_now();
if (empty($ticket['first_responded_at']) && !empty($ticket['first_response_due_at']) && $ticket['first_response_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $ticket['first_response_due_at']);
}
if (empty($ticket['resolved_at']) && !empty($ticket['resolution_due_at']) && $ticket['resolution_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $ticket['resolution_due_at']);
}
}
foreach ([['first_response_due_at', 'first_response', 'Erstantwort'], ['resolution_due_at', 'resolution', 'Lösung']] as [$field, $type, $label]) {
if (empty($ticket[$field])) continue;
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ?");
$check->execute([$ticketId, $type]);
if (!(int) $check->fetchColumn()) {
ticket_sla_history_add($pdo, $ticketId, 'started', $type, $label . '-Frist gestartet; fällig am ' . $ticket[$field], null, (string) $ticket['created_at']);
} else {
$pdo->prepare("UPDATE ticket_sla_history SET details = ? WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ? AND details LIKE 'SLA gestartet; Fristfeld:%'")
->execute([$label . '-Frist gestartet; fällig am ' . $ticket[$field], $ticketId, $type]);
}
}
$stmt = $pdo->prepare('SELECT h.*, s.name AS staff_name FROM ticket_sla_history h LEFT JOIN staff s ON s.id = h.staff_id WHERE h.ticket_id = ? ORDER BY h.created_at DESC, h.id DESC LIMIT 100');
$stmt->execute([$ticketId]);
return $stmt->fetchAll();
} catch (Throwable $e) {
error_log('DeskHub SLA-Verlauf konnte nicht geladen werden: ' . $e->getMessage());
return [];
}
}
function ticket_sla_log_overdue(PDO $pdo, int $ticketId, string $deadlineType, string $dueAt): void
{
try {
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'overdue' AND deadline_type = ?");
$check->execute([$ticketId, $deadlineType]);
if (!(int) $check->fetchColumn()) ticket_sla_history_add($pdo, $ticketId, 'overdue', $deadlineType, 'SLA-Frist überschritten', null, $dueAt);
} catch (Throwable $e) { error_log('DeskHub SLA-Überschreitung konnte nicht protokolliert werden: ' . $e->getMessage()); }
}
/**
* Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung.
* null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
@@ -558,9 +753,39 @@ function ip_in_list(string $ip, array $list): bool
}
/** Kommt die Anfrage direkt von einem als vertrauenswürdig eingetragenen Reverse-Proxy (config site → trusted_proxies)? */
function cloudflare_proxy_ranges(): array
{
static $ranges = null;
if ($ranges !== null) return $ranges;
$ranges = [];
try {
$active = get_db()->prepare('SELECT is_active FROM plugins WHERE slug = ?');
$active->execute(['cloudflare-protection']);
if ((int) $active->fetchColumn() === 1) {
$dynamic = json_decode((string) get_setting('plugin.cloudflare-protection.ranges', ''), true);
if (is_array($dynamic)) $ranges = array_values(array_filter($dynamic, 'is_string'));
}
} catch (Throwable $e) {
// No dynamic Cloudflare networks are trusted if the plugin is not installed.
}
return $ranges;
}
function trusted_proxy_ranges(): array
{
static $ranges = null;
if ($ranges === null) {
$ranges = array_values(array_unique(array_merge(
array_map('strval', (array) (config()['site']['trusted_proxies'] ?? [])),
cloudflare_proxy_ranges()
)));
}
return $ranges;
}
function request_from_trusted_proxy(): bool
{
$list = (array) (config()['site']['trusted_proxies'] ?? []);
$list = trusted_proxy_ranges();
return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list);
}
@@ -580,7 +805,12 @@ function client_ip(): string
{
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!request_from_trusted_proxy()) return $remote;
$trusted = (array) (config()['site']['trusted_proxies'] ?? []);
$cloudflareRanges = cloudflare_proxy_ranges();
$cloudflareIp = trim((string) ($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''));
if ($cloudflareRanges !== [] && ip_in_list($remote, $cloudflareRanges) && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
return $cloudflareIp;
}
$trusted = trusted_proxy_ranges();
$chain = array_reverse(array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))));
foreach ($chain as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP) && !ip_in_list($ip, $trusted)) return $ip;
@@ -1513,7 +1743,11 @@ function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool
{
if (empty($ruleResult['close'])) return false;
$now = db_now();
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
$statusStmt = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
$statusStmt->execute([$ticketId]);
$oldStatus = (string) ($statusStmt->fetchColumn() ?: 'open');
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, resolution_code = 'automated', resolution_note = 'Automatisch durch Ticket-Regel geschlossen', updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
ticket_sla_status_transition($pdo, $ticketId, $oldStatus, 'closed');
log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)');
return true;
}