Upload via GUI (340 Dateien)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user