Upload via GUI (396 Dateien)

This commit is contained in:
2026-11-04 07:55:19 +00:00
parent c55cc54ba2
commit 27b3e4dbaf
19 changed files with 1559 additions and 9 deletions
@@ -0,0 +1,218 @@
<?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];
}