Files
deskhub/DeskHub-Webseite/includes/report_schedules.php
T
2026-11-04 07:55:19 +00:00

219 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Berichte per E-Mail nach Zeitplan: Ein gespeicherter Bericht aus dem Bericht-Baukasten wird täglich, wöchentlich oder monatlich
* für einen relativen Zeitraum (z. B. „letzte Woche“) neu berechnet und als Tabelle an Empfänger geschickt
* (cron/send_report_schedules.php). Der Bericht wird mit den Abteilungsrechten des Einrichters berechnet.
*/
const REPORT_SCHEDULE_MAX_RECIPIENTS = 10;
/** @return array<string,string> */
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<string,string> */
function report_schedule_frequencies(): array
{
return ['daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich'];
}
/** @return array<int,string> */
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<string>,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 = '<h2 style="margin:0 0 6px;font-size:20px;color:#0f172a">' . e($saved['name']) . '</h2>'
. '<p style="margin:0 0 14px;color:#64748b;font-size:14px">Zeitraum: ' . e(date('d.m.Y', strtotime($from))) . ' bis ' . e(date('d.m.Y', strtotime($to))) . '</p>'
. '<table style="border-collapse:collapse;width:100%"><thead><tr>';
foreach ($report['columns'] as $c) $html .= '<th ' . $th . '>' . e($c['label']) . '</th>';
$html .= '</tr></thead><tbody>';
foreach ($report['rows'] as $row) {
$html .= '<tr><td ' . $td . '><strong>' . e((string) $row['label']) . '</strong></td>';
foreach (array_slice($report['columns'], 1) as $c) $html .= '<td ' . $td . '>' . e($fmt($row[$c['key']] ?? null, (string) $c['unit'])) . '</td>';
$html .= '</tr>';
}
if (!$report['rows']) {
$html .= '<tr><td ' . $td . ' colspan="' . count($report['columns']) . '">Keine Tickets im Zeitraum.</td></tr>';
} else {
$html .= '<tr><td ' . $td . '><strong>Gesamt</strong></td>';
foreach (array_slice($report['columns'], 1) as $c) $html .= '<td ' . $td . '><strong>' . e($fmt($report['totals'][$c['key']] ?? null, (string) $c['unit'])) . '</strong></td>';
$html .= '</tr>';
}
$html .= '</tbody></table><p style="margin:16px 0 0;color:#94a3b8;font-size:12px">Automatisch versendet von DeskHub (' . e(strtolower(report_schedule_frequencies()[$schedule['frequency']] ?? '')) . ').</p>';
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];
}