219 lines
12 KiB
PHP
219 lines
12 KiB
PHP
<?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];
|
||
}
|