*/ function report_schedule_periods(): array { return [ 'yesterday' => 'Gestern', 'last_7' => 'Letzte 7 Tage', 'last_week' => 'Letzte Kalenderwoche (Mo–So)', 'last_30' => 'Letzte 30 Tage', 'last_month' => 'Letzter Kalendermonat', ]; } /** @return array */ function report_schedule_frequencies(): array { return ['daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich']; } /** @return array */ function report_schedule_weekdays(): array { return [1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag', 5 => 'Freitag', 6 => 'Samstag', 7 => 'Sonntag']; } /** Zeitraum (von, bis; beide Y-m-d) für einen relativen Zeitraum, bezogen auf $now. */ function report_period_range(string $period, ?int $now = null): array { $now = $now ?? time(); $day = fn(string $mod) => date('Y-m-d', strtotime($mod, $now)); switch ($period) { case 'yesterday': return [$day('-1 day'), $day('-1 day')]; case 'last_week': $monday = strtotime('monday this week', $now); return [date('Y-m-d', strtotime('-7 days', $monday)), date('Y-m-d', strtotime('-1 day', $monday))]; case 'last_30': return [$day('-30 days'), $day('-1 day')]; case 'last_month': $first = strtotime(date('Y-m-01', $now)); return [date('Y-m-d', strtotime('-1 month', $first)), date('Y-m-d', strtotime('-1 day', $first))]; case 'last_7': default: return [$day('-7 days'), $day('-1 day')]; } } /** Nächster Sendezeitpunkt (Y-m-d H:i:s) nach $after für den Zeitplan. */ function report_schedule_next(array $s, ?int $after = null): string { $after = $after ?? time(); $time = preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) ($s['send_time'] ?? '')) ? (string) $s['send_time'] : '08:00'; $freq = (string) ($s['frequency'] ?? 'weekly'); $weekday = max(1, min(7, (int) ($s['weekday'] ?? 1))); $monthday = max(1, min(28, (int) ($s['monthday'] ?? 1))); for ($i = 0; $i <= 62; $i++) { $dayTs = strtotime("+{$i} days", strtotime(date('Y-m-d', $after))); if ($freq === 'weekly' && (int) date('N', $dayTs) !== $weekday) continue; if ($freq === 'monthly' && (int) date('j', $dayTs) !== $monthday) continue; if ($freq === 'daily' && !empty($s['weekdays_only']) && (int) date('N', $dayTs) > 5) continue; $candidate = strtotime(date('Y-m-d', $dayTs) . ' ' . $time . ':00'); if ($candidate !== false && $candidate > $after) return date('Y-m-d H:i:s', $candidate); } return date('Y-m-d H:i:s', $after + 86400); } /** * Prüft die Empfängerliste (Komma, Semikolon oder Zeilenumbruch getrennt). Nicht-Administratoren dürfen nur an aktive Mitarbeiter senden. * @return array{0:list,1:?string} [Adressen, Fehlermeldung] */ function report_schedule_recipients(PDO $pdo, string $input, array $staff): array { $list = []; foreach (preg_split('/[\s,;]+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $addr) { $addr = strtolower(trim($addr)); if (!filter_var($addr, FILTER_VALIDATE_EMAIL)) return [[], 'Ungültige E-Mail-Adresse: ' . $addr]; $list[$addr] = $addr; } $list = array_values($list); if (!$list) return [[], 'Bitte mindestens einen Empfänger angeben.']; if (count($list) > REPORT_SCHEDULE_MAX_RECIPIENTS) return [[], 'Höchstens ' . REPORT_SCHEDULE_MAX_RECIPIENTS . ' Empfänger je Zeitplan.']; if (($staff['role'] ?? '') !== 'admin') { $known = array_map('strtolower', $pdo->query('SELECT email FROM staff WHERE is_active = 1')->fetchAll(PDO::FETCH_COLUMN)); foreach ($list as $addr) { if (!in_array($addr, $known, true)) return [[], 'Nur Administratoren dürfen Berichte an Adressen außerhalb der Mitarbeiter senden: ' . $addr]; } } return [$list, null]; } /** Legt einen Zeitplan an. Gibt eine Fehlermeldung zurück oder null. */ function report_schedule_create(PDO $pdo, array $staff, array $in): ?string { $stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?'); $stmt->execute([(int) ($in['report_id'] ?? 0)]); $report = $stmt->fetch(); if (!$report || ((int) $report['staff_id'] !== (int) $staff['id'] && !(int) $report['is_shared'] && ($staff['role'] ?? '') !== 'admin')) return 'Bericht nicht gefunden.'; [$recipients, $err] = report_schedule_recipients($pdo, (string) ($in['recipients'] ?? ''), $staff); if ($err) return $err; $freq = isset(report_schedule_frequencies()[$in['frequency'] ?? '']) ? (string) $in['frequency'] : 'weekly'; $period = isset(report_schedule_periods()[$in['period'] ?? '']) ? (string) $in['period'] : 'last_7'; $time = preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) ($in['send_time'] ?? '')) ? (string) $in['send_time'] : '08:00'; $row = [ 'frequency' => $freq, 'weekday' => max(1, min(7, (int) ($in['weekday'] ?? 1))), 'monthday' => max(1, min(28, (int) ($in['monthday'] ?? 1))), 'send_time' => $time, 'weekdays_only' => !empty($in['weekdays_only']) ? 1 : 0, ]; // Weniger Mails: Standard ist „nur bei Daten“ und „nur bei Änderung“ (ein leeres Kontrollkästchen im Formular schaltet es ab, siehe „quiet_form“) $onlyData = !empty($in['quiet_form']) ? (!empty($in['only_if_data']) ? 1 : 0) : 1; $skipSame = !empty($in['quiet_form']) ? (!empty($in['skip_unchanged']) ? 1 : 0) : 1; $pdo->prepare('INSERT INTO report_schedules (report_id, staff_id, recipients, frequency, weekday, monthday, send_time, period, enabled, next_run_at, created_at, only_if_data, skip_unchanged, weekdays_only) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)') ->execute([(int) $report['id'], (int) $staff['id'], implode(', ', $recipients), $freq, $row['weekday'], $row['monthday'], $time, $period, 1, report_schedule_next($row), db_now(), $onlyData, $skipSame, $row['weekdays_only']]); return null; } /** Baut Betreff und HTML-Text für einen Zeitplan. @return array{0:string,1:string,2:int,3:string}|null [Betreff, HTML, Zeilen, Prüfsumme] oder null, wenn der Bericht fehlt. */ function report_schedule_render(PDO $pdo, array $schedule, ?int $now = null): ?array { $stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?'); $stmt->execute([(int) $schedule['report_id']]); $saved = $stmt->fetch(); if (!$saved) return null; $owner = $pdo->prepare('SELECT * FROM staff WHERE id = ?'); $owner->execute([(int) $schedule['staff_id']]); $ownerRow = $owner->fetch(); if (!$ownerRow || (isset($ownerRow['is_active']) && (int) $ownerRow['is_active'] === 0)) return null; [$from, $to] = report_period_range((string) $schedule['period'], $now); $cfgIn = json_decode((string) $saved['config'], true) ?: []; $cfgIn['from'] = $from; $cfgIn['to'] = $to; $cfg = report_clean_config($pdo, $cfgIn); $report = report_run($pdo, $cfg, staff_department_ids($ownerRow)); $fmt = function ($v, string $unit): string { if ($v === null || $v === '') return '–'; if ($unit === '%' || $unit === 'Std') return number_format((float) $v, 1, ',', '.') . ($unit === '%' ? ' %' : ' Std'); return (string) $v; }; $th = 'style="text-align:left;padding:6px 10px;border-bottom:2px solid #cbd5e1;background:#f1f5f9;font-size:13px"'; $td = 'style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:13px"'; $html = '

' . e($saved['name']) . '

' . '

Zeitraum: ' . e(date('d.m.Y', strtotime($from))) . ' bis ' . e(date('d.m.Y', strtotime($to))) . '

' . ''; foreach ($report['columns'] as $c) $html .= ''; $html .= ''; foreach ($report['rows'] as $row) { $html .= ''; foreach (array_slice($report['columns'], 1) as $c) $html .= ''; $html .= ''; } if (!$report['rows']) { $html .= ''; } else { $html .= ''; foreach (array_slice($report['columns'], 1) as $c) $html .= ''; $html .= ''; } $html .= '
' . e($c['label']) . '
' . e((string) $row['label']) . '' . e($fmt($row[$c['key']] ?? null, (string) $c['unit'])) . '
Keine Tickets im Zeitraum.
Gesamt' . e($fmt($report['totals'][$c['key']] ?? null, (string) $c['unit'])) . '

Automatisch versendet von DeskHub (' . e(strtolower(report_schedule_frequencies()[$schedule['frequency']] ?? '')) . ').

'; if (function_exists('email_wrap')) $html = email_wrap($html); $subject = 'DeskHub-Bericht: ' . $saved['name'] . ' (' . date('d.m.', strtotime($from)) . '–' . date('d.m.Y', strtotime($to)) . ')'; // Prüfsumme nur der Zahlen (ohne Zeitraum): gleiche Zahlen wie beim letzten Versand = „nichts Neues“ $hash = sha1(json_encode([$report['columns'], $report['rows'], $report['totals']], JSON_UNESCAPED_UNICODE) ?: ''); return [$subject, $html, count($report['rows']), $hash]; } /** * Sendet einen Zeitplan. Mit $respectQuiet = true (automatischer Lauf) wird nichts gesendet, wenn der Bericht leer ist („nur bei Daten“) * oder dieselben Zahlen enthält wie beim letzten Versand („nur bei Änderung“); „Test senden“ sendet immer. * @return array{sent:int,error:?string,skipped:?string,hash:?string} */ function report_schedule_send(PDO $pdo, array $schedule, bool $respectQuiet = false): array { $rendered = report_schedule_render($pdo, $schedule); if ($rendered === null) return ['sent' => 0, 'error' => 'Bericht oder Einrichter existiert nicht mehr oder ist deaktiviert.', 'skipped' => null, 'hash' => null]; [$subject, $html, $rows, $hash] = $rendered; if ($respectQuiet) { if (!empty($schedule['only_if_data']) && $rows === 0) return ['sent' => 0, 'error' => null, 'skipped' => 'Übersprungen: keine Daten im Zeitraum', 'hash' => $hash]; if (!empty($schedule['skip_unchanged']) && !empty($schedule['last_hash']) && hash_equals((string) $schedule['last_hash'], $hash)) { return ['sent' => 0, 'error' => null, 'skipped' => 'Übersprungen: keine Änderung seit dem letzten Versand', 'hash' => $hash]; } } $sent = 0; foreach (preg_split('/[\s,;]+/', (string) $schedule['recipients'], -1, PREG_SPLIT_NO_EMPTY) ?: [] as $to) { if (send_mail($to, $subject, $html)) $sent++; } return ['sent' => $sent, 'error' => $sent === 0 ? 'Kein Empfänger erreicht (E-Mail-Versand prüfen).' : null, 'skipped' => null, 'hash' => $hash]; } /** Sendet alle fälligen Zeitpläne. @return array{sent:int,failed:int} */ function report_schedules_run_due(PDO $pdo): array { $now = db_now(); $stmt = $pdo->prepare('SELECT * FROM report_schedules WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at LIMIT 50'); $stmt->execute([$now]); $ok = 0; $bad = 0; foreach ($stmt->fetchAll() as $s) { // Zeitplan atomar weiterschalten: nur ein Lauf darf senden, auch bei gleichzeitigen Anfragen $next = report_schedule_next($s); $claim = $pdo->prepare('UPDATE report_schedules SET next_run_at = ? WHERE id = ? AND next_run_at = ?'); $claim->execute([$next, (int) $s['id'], $s['next_run_at']]); if ($claim->rowCount() === 0) continue; try { $r = report_schedule_send($pdo, $s, true); } catch (Throwable $e) { $r = ['sent' => 0, 'error' => $e->getMessage(), 'skipped' => null, 'hash' => null]; } $pdo->prepare('UPDATE report_schedules SET last_sent_at = ?, last_error = ?, last_note = ?, last_hash = ? WHERE id = ?') ->execute([$r['sent'] > 0 ? $now : $s['last_sent_at'], $r['error'], $r['skipped'], $r['sent'] > 0 ? $r['hash'] : ($s['last_hash'] ?? null), (int) $s['id']]); if ($r['error'] !== null) $bad++; elseif ($r['skipped'] === null) $ok++; } return ['sent' => $ok, 'failed' => $bad]; }