query( 'SELECT er.*, p.name AS prio_name, s.name AS staff_name FROM escalation_rules er LEFT JOIN priorities p ON p.id = er.action_set_priority_id LEFT JOIN staff s ON s.id = er.action_assign_staff_id WHERE er.is_active = 1 ORDER BY er.sort_order, er.id' )->fetchAll(); } catch (Throwable $e) { $log[] = "[" . date('Y-m-d H:i:s') . "] Tabelle escalation_rules nicht gefunden – Migration ausstehend."; return ['applied' => 0, 'skipped' => 0, 'log' => $log]; } if (!$rules) { $log[] = "[" . date('Y-m-d H:i:s') . "] Keine aktiven Eskalationsregeln."; return ['applied' => 0, 'skipped' => 0, 'log' => $log]; } // Bereits eskalierte Ticket-Regel-Paare laden (verhindert Doppel-Eskalation) $alreadyDone = []; $logRows = $pdo->query('SELECT rule_id, ticket_id FROM escalation_log')->fetchAll(); foreach ($logRows as $lr) { $alreadyDone[$lr['rule_id'] . '_' . $lr['ticket_id']] = true; } foreach ($rules as $rule) { // ── Betroffene Tickets finden ───────────────────────────────────────────── $where = ["t.status NOT IN ('resolved','closed')"]; $params = []; $hours = (int)$rule['trigger_hours']; // Bedingungen aus der Regel if ($rule['condition_status']) { $statusList = array_map('trim', explode(',', $rule['condition_status'])); $ph = implode(',', array_fill(0, count($statusList), '?')); $where[] = "t.status IN ($ph)"; $params = array_merge($params, $statusList); } if ($rule['condition_priority_id']) { $where[] = 't.priority_id = ?'; $params[] = (int)$rule['condition_priority_id']; } if ($rule['condition_dept_id']) { $where[] = 't.department_id = ?'; $params[] = (int)$rule['condition_dept_id']; } // Trigger-spezifische Bedingung $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $hoursDiff = $driver === 'sqlite' ? "((julianday('now', 'localtime') - julianday(%s)) * 24)" : "TIMESTAMPDIFF(HOUR, %s, NOW())"; switch ($rule['trigger_type']) { case 'no_reply': // Kein Mitarbeiter-Thread seit X Stunden $where[] = sprintf( "NOT EXISTS ( SELECT 1 FROM ticket_threads tt WHERE tt.ticket_id = t.id AND tt.author_type = 'staff' AND " . str_replace('%s', 'tt.created_at', $hoursDiff) . " < ? )", '' ); // Simpler: letzter Staff-Thread ist älter als X Stunden ODER kein Staff-Thread vorhanden // Rebuild properly: $where[count($where)-1] = $driver === 'sqlite' ? "((julianday('now', 'localtime') - julianday(t.created_at)) * 24) >= ? AND NOT EXISTS ( SELECT 1 FROM ticket_threads tt WHERE tt.ticket_id = t.id AND tt.author_type = 'staff' AND ((julianday('now', 'localtime') - julianday(tt.created_at)) * 24) < ? )" : "TIMESTAMPDIFF(HOUR, t.created_at, NOW()) >= ? AND NOT EXISTS ( SELECT 1 FROM ticket_threads tt WHERE tt.ticket_id = t.id AND tt.author_type = 'staff' AND TIMESTAMPDIFF(HOUR, tt.created_at, NOW()) < ? )"; $params[] = $hours; $params[] = $hours; break; case 'no_update': $where[] = $driver === 'sqlite' ? "((julianday('now', 'localtime') - julianday(t.updated_at)) * 24) >= ?" : "TIMESTAMPDIFF(HOUR, t.updated_at, NOW()) >= ?"; $params[] = $hours; break; case 'overdue': $where[] = 't.is_overdue = 1'; $where[] = 't.resolution_due_at IS NOT NULL'; break; case 'open_too_long': $where[] = $driver === 'sqlite' ? "((julianday('now', 'localtime') - julianday(t.created_at)) * 24) >= ?" : "TIMESTAMPDIFF(HOUR, t.created_at, NOW()) >= ?"; $params[] = $hours; break; } $sql = 'SELECT t.*, d.name AS department_name, d.email AS department_email, p.name AS priority_name, s.name AS staff_name, s.email AS staff_email, s.is_on_vacation AS staff_on_vacation FROM tickets t JOIN departments d ON d.id = t.department_id JOIN priorities p ON p.id = t.priority_id LEFT JOIN staff s ON s.id = t.assigned_staff_id WHERE ' . implode(' AND ', $where); $stmt = $pdo->prepare($sql); $stmt->execute($params); $tickets = $stmt->fetchAll(); foreach ($tickets as $ticket) { $key = $rule['id'] . '_' . $ticket['id']; if (isset($alreadyDone[$key])) { $skipped++; continue; } // ── Aktionen ausführen ──────────────────────────────────────────────── $changes = []; if ($rule['action_set_priority_id']) { $pdo->prepare('UPDATE tickets SET priority_id = ?, updated_at = ? WHERE id = ?') ->execute([$rule['action_set_priority_id'], $now, $ticket['id']]); $changes[] = 'Priorität gesetzt auf: ' . ($rule['prio_name'] ?? '?'); } if ($rule['action_assign_staff_id']) { $pdo->prepare('UPDATE tickets SET assigned_staff_id = ?, updated_at = ? WHERE id = ?') ->execute([$rule['action_assign_staff_id'], $now, $ticket['id']]); $changes[] = 'Zugewiesen an: ' . ($rule['staff_name'] ?? '?'); } if ($rule['action_set_status']) { $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?') ->execute([$rule['action_set_status'], $now, $ticket['id']]); ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], (string) $rule['action_set_status']); $changes[] = 'Status gesetzt auf: ' . $rule['action_set_status']; } // System-Notiz im Ticket $noteText = "Eskalationsregel „{$rule['name']}“ ausgelöst: " . implode(', ', $changes ?: ['Benachrichtigung']); $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,1,?)' )->execute([$ticket['id'], 'system', $noteText, $now]); log_activity($pdo, $ticket['id'], null, $noteText); // Admin benachrichtigen if ($rule['action_notify_admin']) { notify_admins_overdue_ticket(array_merge($ticket, [ 'subject' => "[Eskalation: {$rule['name']}] " . $ticket['subject'], ])); } // Zugewiesenen Mitarbeiter benachrichtigen if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) { // Bearbeiter im Urlaub: die Vertretung bekommt die Mail $escTo = vacation_expand_ids($pdo, [(int) $ticket['assigned_staff_id']]); $ticket['staff_email'] = $escTo ? (staff_emails_by_ids($pdo, [$escTo[0]])[0] ?? '') : ''; } if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) { $subj = "Eskalation: Ticket #{$ticket['ticket_number']} – {$ticket['subject']}"; $inner = email_p('Die Eskalationsregel ' . e($rule['name']) . ' wurde für folgendes Ticket ausgelöst:') . email_badge('Ticket', '#' . $ticket['ticket_number'], '#b91c1c', '#fee2e2') . email_p('Aktionen: ' . e(implode(', ', $changes ?: ['Benachrichtigung'])), '24px') . email_button('Ticket öffnen', e(base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number'])), '#dc2626'); email_send_and_log($ticket['staff_email'], $subj, email_wrap($inner), null, 'Eskalations-Mail', $ticket['ticket_number']); } // Als erledigt markieren $pdo->prepare( 'INSERT INTO escalation_log (rule_id, ticket_id, applied_at) VALUES (?,?,?)' )->execute([$rule['id'], $ticket['id'], $now]); $alreadyDone[$key] = true; $applied++; } } set_setting('cron_check_escalations_last_run', $now); $log[] = "[" . date('Y-m-d H:i:s') . "] Eskalations-Check abgeschlossen. $applied Regel(n) angewendet, $skipped bereits erledigt."; return ['applied' => $applied, 'skipped' => $skipped, 'log' => $log]; } if (php_sapi_name() === 'cli') { $result = run_check_escalations(get_db(), true); foreach ($result['log'] as $line) { echo $line . "\n"; } }