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