prepare( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'time_error_notifications' AND COLUMN_NAME = 'error_date'" ); $legacyColumnStmt->execute(); if ((int)$legacyColumnStmt->fetchColumn() > 0) { $pdo->exec("DROP TABLE IF EXISTS time_error_notifications"); } $pdo->exec( "CREATE TABLE IF NOT EXISTS time_error_notification_state ( employee_id INT NOT NULL, cycle_started_on DATE NOT NULL, first_error_date DATE NOT NULL, last_notification_stage VARCHAR(50) DEFAULT NULL, last_notification_sent_at DATETIME DEFAULT NULL, employee_day_1_sent_at DATETIME DEFAULT NULL, employee_day_3_sent_at DATETIME DEFAULT NULL, admin_day_7_sent_at DATETIME DEFAULT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (employee_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); $pdo->exec( "CREATE TABLE IF NOT EXISTS time_error_notifications ( id INT NOT NULL AUTO_INCREMENT, employee_id INT NOT NULL, cycle_started_on DATE NOT NULL, notification_stage VARCHAR(50) NOT NULL, recipient_email VARCHAR(255) NOT NULL, sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uniq_time_error_notification (employee_id, cycle_started_on, notification_stage, recipient_email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); } } if (!function_exists('timeErrorNotificationsSendMail')) { function timeErrorNotificationsSendMail(PDO $pdo, string $recipient, string $subject, string $body): bool { $stmt = $pdo->prepare("SELECT * FROM config LIMIT 1"); $stmt->execute(); $config = $stmt->fetch(PDO::FETCH_ASSOC); if (!$config) { return false; } $mail = new PHPMailer(true); try { $mail->SMTPDebug = 0; $mail->isSMTP(); $mail->Host = (string)$config['mailserver']; $mail->SMTPAuth = true; $mail->Username = (string)$config['mailUsername']; $mail->Password = (string)$config['mailPassword']; if (!empty($config['mailSMTPSecure'])) { $mail->SMTPSecure = (string)$config['mailSMTPSecure']; } $mail->Port = (int)$config['mailPort']; $mail->CharSet = 'UTF-8'; $mail->setFrom((string)$config['mailFrom'], (string)$config['mailFromName']); $mail->addAddress($recipient); $mail->isHTML(true); $mail->Subject = $subject; $mail->Body = $body; $mail->AltBody = trim(html_entity_decode(strip_tags(str_replace(["
", "
", "
"], "\n", $body)), ENT_QUOTES, 'UTF-8')); $mail->send(); return true; } catch (Exception $e) { return false; } } } if (!function_exists('timeErrorNotificationsFetchInvalidEntries')) { function timeErrorNotificationsFetchInvalidEntries(PDO $pdo): array { $stmt = $pdo->prepare( "SELECT u.id AS employee_id, u.email, u.vorname, u.nachname, DATE(t.timestamp_datetime) AS error_date, GROUP_CONCAT(t.timestamp_type ORDER BY t.timestamp_datetime) AS day_sequence FROM users u INNER JOIN timestamps t ON t.employee_id = u.id WHERE u.zeiterfassung = '1' AND DATE(t.timestamp_datetime) < CURDATE() GROUP BY u.id, u.email, u.vorname, u.nachname, DATE(t.timestamp_datetime) ORDER BY error_date ASC, u.nachname ASC, u.vorname ASC" ); $stmt->execute(); $entries = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { if (!isValidSequence((string)$row['day_sequence'])) { $entries[] = $row; } } return $entries; } } if (!function_exists('timeErrorNotificationsGroupEntriesByEmployee')) { function timeErrorNotificationsGroupEntriesByEmployee(array $entries): array { $grouped = []; foreach ($entries as $entry) { $employeeId = (int)$entry['employee_id']; if (!isset($grouped[$employeeId])) { $grouped[$employeeId] = [ 'employee_id' => $employeeId, 'email' => (string)$entry['email'], 'vorname' => (string)$entry['vorname'], 'nachname' => (string)$entry['nachname'], 'first_error_date' => (string)$entry['error_date'], 'error_dates' => [], ]; } $grouped[$employeeId]['error_dates'][] = (string)$entry['error_date']; if ((string)$entry['error_date'] < $grouped[$employeeId]['first_error_date']) { $grouped[$employeeId]['first_error_date'] = (string)$entry['error_date']; } } foreach ($grouped as &$employee) { $employee['error_dates'] = array_values(array_unique($employee['error_dates'])); sort($employee['error_dates']); } unset($employee); return $grouped; } } if (!function_exists('timeErrorNotificationsFetchAdminRecipients')) { function timeErrorNotificationsFetchAdminRecipients(PDO $pdo): array { $stmt = $pdo->prepare( "SELECT DISTINCT u.id, u.email, u.vorname, u.nachname FROM users u LEFT JOIN users_admin ua ON ua.userid = u.id WHERE (u.admin = 1 OR ua.userid IS NOT NULL) AND u.email IS NOT NULL AND u.email <> '' ORDER BY u.nachname ASC, u.vorname ASC" ); $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } } if (!function_exists('timeErrorNotificationsFetchStateByEmployee')) { function timeErrorNotificationsFetchStateByEmployee(PDO $pdo): array { timeErrorNotificationsEnsureTables($pdo); $stmt = $pdo->prepare("SELECT * FROM time_error_notification_state"); $stmt->execute(); $states = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $states[(int)$row['employee_id']] = $row; } return $states; } } if (!function_exists('timeErrorNotificationsUpsertState')) { function timeErrorNotificationsUpsertState(PDO $pdo, array $employee, ?array $existingState): array { timeErrorNotificationsEnsureTables($pdo); $cycleStartedOn = $existingState['cycle_started_on'] ?? $employee['first_error_date']; $stmt = $pdo->prepare( "INSERT INTO time_error_notification_state ( employee_id, cycle_started_on, first_error_date, last_notification_stage, last_notification_sent_at, employee_day_1_sent_at, employee_day_3_sent_at, admin_day_7_sent_at ) VALUES ( :employee_id, :cycle_started_on, :first_error_date, :last_notification_stage, :last_notification_sent_at, :employee_day_1_sent_at, :employee_day_3_sent_at, :admin_day_7_sent_at ) ON DUPLICATE KEY UPDATE cycle_started_on = VALUES(cycle_started_on), first_error_date = VALUES(first_error_date), last_notification_stage = VALUES(last_notification_stage), last_notification_sent_at = VALUES(last_notification_sent_at), employee_day_1_sent_at = VALUES(employee_day_1_sent_at), employee_day_3_sent_at = VALUES(employee_day_3_sent_at), admin_day_7_sent_at = VALUES(admin_day_7_sent_at)" ); $stmt->execute([ 'employee_id' => $employee['employee_id'], 'cycle_started_on' => $cycleStartedOn, 'first_error_date' => $employee['first_error_date'], 'last_notification_stage' => $existingState['last_notification_stage'] ?? null, 'last_notification_sent_at' => $existingState['last_notification_sent_at'] ?? null, 'employee_day_1_sent_at' => $existingState['employee_day_1_sent_at'] ?? null, 'employee_day_3_sent_at' => $existingState['employee_day_3_sent_at'] ?? null, 'admin_day_7_sent_at' => $existingState['admin_day_7_sent_at'] ?? null, ]); return [ 'employee_id' => $employee['employee_id'], 'cycle_started_on' => $cycleStartedOn, 'first_error_date' => $employee['first_error_date'], 'last_notification_stage' => $existingState['last_notification_stage'] ?? null, 'last_notification_sent_at' => $existingState['last_notification_sent_at'] ?? null, 'employee_day_1_sent_at' => $existingState['employee_day_1_sent_at'] ?? null, 'employee_day_3_sent_at' => $existingState['employee_day_3_sent_at'] ?? null, 'admin_day_7_sent_at' => $existingState['admin_day_7_sent_at'] ?? null, ]; } } if (!function_exists('timeErrorNotificationsClearResolvedStates')) { function timeErrorNotificationsClearResolvedStates(PDO $pdo, array $openEmployeeIds): int { timeErrorNotificationsEnsureTables($pdo); if (empty($openEmployeeIds)) { $stmt = $pdo->prepare("DELETE FROM time_error_notification_state"); $stmt->execute(); return $stmt->rowCount(); } $placeholders = implode(',', array_fill(0, count($openEmployeeIds), '?')); $stmt = $pdo->prepare("DELETE FROM time_error_notification_state WHERE employee_id NOT IN ($placeholders)"); $stmt->execute(array_values($openEmployeeIds)); return $stmt->rowCount(); } } if (!function_exists('timeErrorNotificationsMarkStageSent')) { function timeErrorNotificationsMarkStageSent(PDO $pdo, array $state, string $stage, string $recipientEmail): array { timeErrorNotificationsEnsureTables($pdo); $stmt = $pdo->prepare( "INSERT INTO time_error_notifications (employee_id, cycle_started_on, notification_stage, recipient_email) VALUES (:employee_id, :cycle_started_on, :notification_stage, :recipient_email) ON DUPLICATE KEY UPDATE sent_at = sent_at" ); $stmt->execute([ 'employee_id' => $state['employee_id'], 'cycle_started_on' => $state['cycle_started_on'], 'notification_stage' => $stage, 'recipient_email' => $recipientEmail, ]); $now = (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'); $state['last_notification_stage'] = $stage; $state['last_notification_sent_at'] = $now; if ($stage === 'employee_day_1') { $state['employee_day_1_sent_at'] = $now; } elseif ($stage === 'employee_day_3') { $state['employee_day_3_sent_at'] = $now; } elseif ($stage === 'admin_day_7') { $state['admin_day_7_sent_at'] = $now; } $stmt = $pdo->prepare( "UPDATE time_error_notification_state SET last_notification_stage = :last_notification_stage, last_notification_sent_at = :last_notification_sent_at, employee_day_1_sent_at = :employee_day_1_sent_at, employee_day_3_sent_at = :employee_day_3_sent_at, admin_day_7_sent_at = :admin_day_7_sent_at WHERE employee_id = :employee_id" ); $stmt->execute([ 'last_notification_stage' => $state['last_notification_stage'], 'last_notification_sent_at' => $state['last_notification_sent_at'], 'employee_day_1_sent_at' => $state['employee_day_1_sent_at'], 'employee_day_3_sent_at' => $state['employee_day_3_sent_at'], 'admin_day_7_sent_at' => $state['admin_day_7_sent_at'], 'employee_id' => $state['employee_id'], ]); return $state; } } if (!function_exists('timeErrorNotificationsDaysSinceDate')) { function timeErrorNotificationsDaysSinceDate(string $date, ?DateTimeImmutable $today = null): int { $today = $today ?: new DateTimeImmutable('today'); $reference = new DateTimeImmutable(substr($date, 0, 10)); return (int)$reference->diff($today)->days; } } if (!function_exists('timeErrorNotificationsFormatDateList')) { function timeErrorNotificationsFormatDateList(array $errorDates): string { $labels = array_map(static function (string $date): string { return date('d.m.Y', strtotime($date)); }, $errorDates); return implode(', ', $labels); } } if (!function_exists('timeErrorNotificationsBuildEmployeeMail')) { function timeErrorNotificationsGetTrackingUrl(): string { if (!empty($_SERVER['HTTP_HOST'])) { $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://'; return $protocol . $_SERVER['HTTP_HOST'] . '/zeiterfassung/'; } return 'https://www.praxis-creutzburg.de/zeiterfassung/'; } } if (!function_exists('timeErrorNotificationsBuildEmployeeMail')) { function timeErrorNotificationsBuildEmployeeMail(array $employee, string $stage): array { $name = trim($employee['vorname'] . ' ' . $employee['nachname']); $dateList = timeErrorNotificationsFormatDateList($employee['error_dates']); $trackingUrl = timeErrorNotificationsGetTrackingUrl(); $subject = ($stage === 'employee_day_3') ? 'Erneute Erinnerung zu offenen Zeitfehlern' : 'Erinnerung zu offenen Zeitfehlern'; $body = '

Hallo ' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . ',

' . '

in deiner Zeiterfassung gibt es weiterhin offene Buchungsfehler.

' . '

Betroffene Tage: ' . htmlspecialchars($dateList, ENT_QUOTES, 'UTF-8') . '

' . '

Bitte korrigiere die Eintraege in der Zeiterfassung. Solange die Fehler offen bleiben, wird der Vorgang weiter verfolgt.

' . '

Zur Zeiterfassung

'; return [ 'subject' => $subject, 'body' => $body, ]; } } if (!function_exists('timeErrorNotificationsBuildAdminMail')) { function timeErrorNotificationsBuildAdminMail(array $employee): array { $employeeName = trim($employee['vorname'] . ' ' . $employee['nachname']); $dateList = timeErrorNotificationsFormatDateList($employee['error_dates']); $trackingUrl = timeErrorNotificationsGetTrackingUrl(); $subject = 'Eskalation: Offene Zeitfehler von ' . $employeeName; $body = '

Bei einem Mitarbeiter bestehen weiterhin offene Zeitfehler.

' . '

Mitarbeiter: ' . htmlspecialchars($employeeName, ENT_QUOTES, 'UTF-8') . '
' . 'E-Mail: ' . htmlspecialchars($employee['email'], ENT_QUOTES, 'UTF-8') . '
' . 'Betroffene Tage: ' . htmlspecialchars($dateList, ENT_QUOTES, 'UTF-8') . '

' . '

Bitte pruefen Sie die Zeiterfassung und stimmen Sie die Korrektur mit dem Mitarbeiter ab.

' . '

Zur Zeiterfassung

'; return [ 'subject' => $subject, 'body' => $body, ]; } } if (!function_exists('timeErrorNotificationsProcess')) { function timeErrorNotificationsProcess(PDO $pdo, ?DateTimeImmutable $today = null): array { $today = $today ?: new DateTimeImmutable('today'); timeErrorNotificationsEnsureTables($pdo); $results = [ 'processed_errors' => 0, 'affected_employees' => 0, 'employee_day_1_sent' => 0, 'employee_day_3_sent' => 0, 'admin_day_7_sent' => 0, 'resolved_states_cleared' => 0, 'skipped_missing_employee_email' => 0, 'skipped_missing_admin_email' => 0, 'failed' => [], ]; $entries = timeErrorNotificationsFetchInvalidEntries($pdo); $employees = timeErrorNotificationsGroupEntriesByEmployee($entries); $adminRecipients = timeErrorNotificationsFetchAdminRecipients($pdo); $states = timeErrorNotificationsFetchStateByEmployee($pdo); $results['processed_errors'] = count($entries); $results['affected_employees'] = count($employees); $results['resolved_states_cleared'] = timeErrorNotificationsClearResolvedStates($pdo, array_keys($employees)); foreach ($employees as $employeeId => $employee) { $state = timeErrorNotificationsUpsertState($pdo, $employee, $states[$employeeId] ?? null); $daysSinceCycleStart = timeErrorNotificationsDaysSinceDate($state['cycle_started_on'], $today); $employeeEmail = trim($employee['email']); if ($state['employee_day_1_sent_at'] === null && $daysSinceCycleStart >= 1) { if ($employeeEmail === '') { $results['skipped_missing_employee_email']++; continue; } $mail = timeErrorNotificationsBuildEmployeeMail($employee, 'employee_day_1'); if (timeErrorNotificationsSendMail($pdo, $employeeEmail, $mail['subject'], $mail['body'])) { $state = timeErrorNotificationsMarkStageSent($pdo, $state, 'employee_day_1', $employeeEmail); $results['employee_day_1_sent']++; } else { $results['failed'][] = [ 'stage' => 'employee_day_1', 'employee_id' => $employeeId, 'recipient_email' => $employeeEmail, ]; } continue; } if ($state['employee_day_3_sent_at'] === null && $daysSinceCycleStart >= 3) { if ($employeeEmail === '') { $results['skipped_missing_employee_email']++; continue; } $mail = timeErrorNotificationsBuildEmployeeMail($employee, 'employee_day_3'); if (timeErrorNotificationsSendMail($pdo, $employeeEmail, $mail['subject'], $mail['body'])) { $state = timeErrorNotificationsMarkStageSent($pdo, $state, 'employee_day_3', $employeeEmail); $results['employee_day_3_sent']++; } else { $results['failed'][] = [ 'stage' => 'employee_day_3', 'employee_id' => $employeeId, 'recipient_email' => $employeeEmail, ]; } continue; } if ($state['admin_day_7_sent_at'] !== null || empty($state['last_notification_sent_at'])) { continue; } $daysSinceLastNotification = timeErrorNotificationsDaysSinceDate($state['last_notification_sent_at'], $today); if ($daysSinceLastNotification < 7) { continue; } if (empty($adminRecipients)) { $results['skipped_missing_admin_email']++; continue; } foreach ($adminRecipients as $adminRecipient) { $adminEmail = trim((string)$adminRecipient['email']); if ($adminEmail === '') { $results['skipped_missing_admin_email']++; continue; } $mail = timeErrorNotificationsBuildAdminMail($employee); if (timeErrorNotificationsSendMail($pdo, $adminEmail, $mail['subject'], $mail['body'])) { $state = timeErrorNotificationsMarkStageSent($pdo, $state, 'admin_day_7', $adminEmail); $results['admin_day_7_sent']++; } else { $results['failed'][] = [ 'stage' => 'admin_day_7', 'employee_id' => $employeeId, 'recipient_email' => $adminEmail, ]; } } } return $results; } }