235 lines
10 KiB
PHP
235 lines
10 KiB
PHP
<?php
|
||
// Direkter Web-Zugriff (Browser/URL) wird geblockt. Ausnahmen: echter CLI-Cronjob
|
||
// sowie Einbindung per require_once aus dem authentifizierten Backend (staff/settings.php),
|
||
// das vor dem require_once die Konstante DESKHUB_CRON_RUNNER definiert.
|
||
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
|
||
/**
|
||
* cron/check_escalations.php
|
||
*
|
||
* Prüft alle aktiven Eskalationsregeln und führt definierte Aktionen aus.
|
||
*
|
||
* Trigger-Typen:
|
||
* no_reply – Ticket seit X Stunden ohne Mitarbeiter-Antwort
|
||
* no_update – Ticket seit X Stunden nicht aktualisiert
|
||
* overdue – Ticket ist überfällig (resolution_due_at überschritten)
|
||
* open_too_long – Ticket seit X Stunden offen
|
||
*
|
||
* Aktionen:
|
||
* - Priorität erhöhen
|
||
* - Mitarbeiter neu zuweisen
|
||
* - Status ändern
|
||
* - Admin benachrichtigen
|
||
* - Zugewiesenen Mitarbeiter benachrichtigen
|
||
*
|
||
* Crontab: 0 * * * * php /pfad/zu/deskhub/cron/check_escalations.php
|
||
*
|
||
* Kann außerdem manuell aus dem Backend (Einstellungen → Cron-Jobs) ausgeführt werden.
|
||
*/
|
||
|
||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||
|
||
/**
|
||
* Führt die Eskalationsprüfung aus. Kann vom Cron und manuell aus settings.php aufgerufen werden.
|
||
*
|
||
* @return array{applied: int, skipped: int, log: string[]}
|
||
*/
|
||
function run_check_escalations(PDO $pdo, bool $verbose = false): array
|
||
{
|
||
$now = db_now();
|
||
$applied = 0;
|
||
$skipped = 0;
|
||
$log = [];
|
||
|
||
// Alle aktiven Eskalationsregeln laden
|
||
try {
|
||
$rules = $pdo->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
|
||
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'])) {
|
||
$subj = "Eskalation: Ticket #{$ticket['ticket_number']} – {$ticket['subject']}";
|
||
$inner = email_p('Die Eskalationsregel <strong>' . e($rule['name']) . '</strong> wurde für folgendes Ticket ausgelöst:')
|
||
. email_badge('Ticket', '#' . $ticket['ticket_number'], '#b91c1c', '#fee2e2')
|
||
. email_p('<strong>Aktionen:</strong> ' . 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";
|
||
}
|
||
}
|