Beschriftung). */ function ticket_snooze_options(): array { return ['1h' => 'In 1 Stunde', '3h' => 'In 3 Stunden', 'tomorrow' => 'Morgen früh (8:00 Uhr)', '3d' => 'In 3 Tagen', '1w' => 'In 1 Woche', 'custom' => 'Zu einem Zeitpunkt …']; } /** Rechnet die Auswahl in einen Zeitpunkt um (Y-m-d H:i:s); null, wenn ungültig oder nicht in der Zukunft (höchstens 1 Jahr). */ function ticket_snooze_until(string $choice, string $custom = ''): ?string { $now = time(); $ts = match ($choice) { '1h' => $now + 3600, '3h' => $now + 3 * 3600, 'tomorrow' => (int) strtotime('tomorrow 08:00'), '3d' => $now + 3 * 86400, '1w' => $now + 7 * 86400, 'custom' => $custom !== '' ? (int) strtotime(str_replace('T', ' ', $custom)) : 0, default => 0, }; if ($ts <= $now + 30 || $ts > $now + 366 * 86400) return null; return date('Y-m-d H:i:s', $ts); } function ticket_snooze_set(PDO $pdo, int $ticketId, int $staffId, string $until): void { $now = db_now(); $pdo->prepare('UPDATE tickets SET snoozed_until = ?, snoozed_by = ?, updated_at = updated_at WHERE id = ?')->execute([$until, $staffId, $ticketId]); log_activity($pdo, $ticketId, $staffId, 'Wiedervorlage bis ' . date('d.m.Y H:i', (int) strtotime($until)) . ' Uhr'); } function ticket_snooze_clear(PDO $pdo, int $ticketId, ?int $staffId, string $reason): void { $stmt = $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL WHERE id = ? AND snoozed_until IS NOT NULL'); $stmt->execute([$ticketId]); if ($stmt->rowCount() > 0) log_activity($pdo, $ticketId, $staffId, $reason); } /** Gibt es die Spalte der Wiedervorlage schon? (Solange die Datenbank nicht aktualisiert ist, bleiben die Listen trotzdem benutzbar.) */ function ticket_snooze_available(): bool { static $ok = null; if ($ok === null) { try { get_db()->query('SELECT snoozed_until FROM tickets LIMIT 1'); $ok = true; } catch (Throwable $e) { $ok = false; } } return $ok; } /** SQL-Bedingung: Ticket ist gerade nicht in der Wiedervorlage (für Listen und Zähler). */ function ticket_not_snoozed_sql(string $alias = 't'): string { if (!ticket_snooze_available()) return '1=1'; return "({$alias}.snoozed_until IS NULL OR {$alias}.snoozed_until <= '" . db_now() . "')"; } /** * Holt fällige Wiedervorlagen zurück: Das Ticket erscheint wieder in den Listen, der Mitarbeiter bekommt eine Benachrichtigung. * * @return int Anzahl der zurückgeholten Tickets */ function ticket_snooze_wake_due(PDO $pdo): int { try { $stmt = $pdo->prepare('SELECT * FROM tickets WHERE snoozed_until IS NOT NULL AND snoozed_until <= ?'); $stmt->execute([db_now()]); $due = $stmt->fetchAll(); } catch (Throwable $e) { return 0; // Spalte fehlt (Datenbank noch nicht aktualisiert) } $woken = 0; foreach ($due as $t) { // Erledigte Tickets (gelöst oder geschlossen) brauchen keine Wiedervorlage mehr: still aufräumen, keine Benachrichtigung if (in_array((string) $t['status'], ['resolved', 'closed'], true)) { $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL WHERE id = ?')->execute([$t['id']]); continue; } $woken++; $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = ? WHERE id = ?')->execute([db_now(), $t['id']]); log_activity($pdo, (int) $t['id'], null, 'Wiedervorlage fällig'); $to = (int) ($t['snoozed_by'] ?: ($t['assigned_staff_id'] ?? 0)); if ($to > 0) { fcm_notify_staff([$to], fcm_data('ticket_snooze', $t), (int) $t['department_id']); if (file_exists(__DIR__ . '/push.php')) { require_once __DIR__ . '/push.php'; push_to_staff($to, 'Wiedervorlage: #' . $t['ticket_number'], mb_strimwidth((string) $t['subject'], 0, 60, '…'), '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number'])); } } } return $woken; } // ── Beobachter ─────────────────────────────────────────────────────────────── function ticket_follow(PDO $pdo, int $ticketId, int $staffId): void { $c = $pdo->prepare('SELECT COUNT(*) FROM ticket_followers WHERE ticket_id = ? AND staff_id = ?'); $c->execute([$ticketId, $staffId]); if ((int) $c->fetchColumn() === 0) { $pdo->prepare('INSERT INTO ticket_followers (ticket_id, staff_id, created_at) VALUES (?,?,?)')->execute([$ticketId, $staffId, db_now()]); } } function ticket_unfollow(PDO $pdo, int $ticketId, int $staffId): void { $pdo->prepare('DELETE FROM ticket_followers WHERE ticket_id = ? AND staff_id = ?')->execute([$ticketId, $staffId]); } /** @return array */ function ticket_followers(PDO $pdo, int $ticketId): array { try { $stmt = $pdo->prepare('SELECT s.id, s.name FROM ticket_followers f JOIN staff s ON s.id = f.staff_id WHERE f.ticket_id = ? AND s.is_active = 1 ORDER BY s.name'); $stmt->execute([$ticketId]); return $stmt->fetchAll(); } catch (Throwable $e) { return []; } } /** * Benachrichtigt die Beobachter eines Tickets (nicht den, der es ausgelöst hat, und nicht den zugewiesenen Mitarbeiter, der ohnehin * Bescheid bekommt). $what ist ein kurzer Text wie "Neue Kundenantwort". */ function ticket_followers_notify(array $ticket, string $what, ?int $actorId = null): void { if (empty($ticket['id'])) return; $pdo = get_db(); $ids = array_map(fn($f) => (int) $f['id'], ticket_followers($pdo, (int) $ticket['id'])); $skip = array_filter([$actorId, (int) ($ticket['assigned_staff_id'] ?? 0)]); $ids = array_values(array_diff($ids, $skip)); if (!$ids) return; $data = fcm_data('ticket_follow', $ticket); $data['body'] = $what . ' · #' . ($ticket['ticket_number'] ?? ''); fcm_notify_staff($ids, $data, (int) ($ticket['department_id'] ?? 0)); if (file_exists(__DIR__ . '/push.php')) { require_once __DIR__ . '/push.php'; foreach ($ids as $id) { push_to_staff($id, 'Beobachtet: #' . ($ticket['ticket_number'] ?? ''), $what, '/staff/ticket_view.php?n=' . urlencode((string) ($ticket['ticket_number'] ?? ''))); } } } // ── Ähnliche Tickets und Artikel ───────────────────────────────────────────── /** * Passende Artikel der Wissensdatenbank und bereits gelöste, ähnliche Tickets zu Betreff (und Kundentext) eines Tickets. * * @param int[]|null $deptIds sichtbare Abteilungen des Mitarbeiters (null = alle) * @return array{articles:array,tickets:array} */ function ticket_similar(PDO $pdo, array $ticket, ?array $deptIds = null, int $limit = 4): array { require_once __DIR__ . '/assistant.php'; $q = assistant_tokens((string) ($ticket['subject'] ?? '')); $out = ['articles' => [], 'tickets' => []]; if (count($q) < 1) return $out; $need = max(1, (int) ceil(count($q) / 2)); foreach ($pdo->query('SELECT id, title, slug, body FROM kb_articles WHERE is_published = 1 ORDER BY id DESC LIMIT 500')->fetchAll() as $a) { $title = array_flip(assistant_tokens($a['title'])); $body = array_flip(assistant_tokens(mb_substr(strip_tags((string) $a['body']), 0, 20000))); $score = 0.0; $hit = 0; foreach ($q as $t) { if (isset($title[$t])) { $score += 4; $hit++; } elseif (isset($body[$t])) { $score += 1.5; $hit++; } } if ($hit >= $need && $score >= 2.5) $out['articles'][] = ['score' => $score, 'title' => $a['title'], 'url' => '/kb_article.php?slug=' . rawurlencode((string) $a['slug'])]; } usort($out['articles'], fn($a, $b) => $b['score'] <=> $a['score']); $out['articles'] = array_slice($out['articles'], 0, 3); $sql = "SELECT id, ticket_number, subject, status FROM tickets WHERE id <> ? AND is_spam = 0 AND status IN ('resolved','closed')"; $params = [(int) $ticket['id']]; if ($deptIds !== null) { if (!$deptIds) return $out; $sql .= ' AND department_id IN (' . implode(',', array_fill(0, count($deptIds), '?')) . ')'; array_push($params, ...$deptIds); } $stmt = $pdo->prepare($sql . ' ORDER BY id DESC LIMIT 800'); $stmt->execute($params); foreach ($stmt->fetchAll() as $t) { $words = array_flip(assistant_tokens((string) $t['subject'])); $hit = 0; foreach ($q as $w) if (isset($words[$w])) $hit++; if ($hit >= max(2, $need) || ($hit >= 1 && count($q) === 1)) $out['tickets'][] = ['score' => $hit, 'number' => $t['ticket_number'], 'subject' => $t['subject'], 'status' => $t['status']]; } usort($out['tickets'], fn($a, $b) => $b['score'] <=> $a['score']); $out['tickets'] = array_slice($out['tickets'], 0, $limit); return $out; } // ── Erinnerung an Kunden ───────────────────────────────────────────────────── function customer_reminder_enabled(): bool { return get_setting('remind_enabled', '0') === '1'; } function customer_reminder_days(): int { return max(1, min(60, (int) get_setting('remind_after_days', '3'))); }