From 008451641459a1d208308e18a3b3966905fc3c9f Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Mon, 30 Mar 2026 20:34:27 +0200 Subject: [PATCH] zeiterfassung --- .vscode/settings.json | 18 + Praxis Creutzburg.session.sql | 24 + ...zeiterfassung_time_error_notifications.sql | 24 + .../inc/time_error_notifications.inc.php | 501 ++++++++++++++++++ zeiterfassung/sendTimeErrorNotifications.php | 54 ++ 5 files changed, 621 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 Praxis Creutzburg.session.sql create mode 100644 admin/sql/2026-03-30_zeiterfassung_time_error_notifications.sql create mode 100644 zeiterfassung/inc/time_error_notifications.inc.php create mode 100644 zeiterfassung/sendTimeErrorNotifications.php diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e58d8a8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "sqltools.connections": [ + { + "mysqlOptions": { + "authProtocol": "default", + "enableSsl": "Disabled" + }, + "ssh": "Disabled", + "previewLimit": 50, + "server": "mysql2fda.netcup.net", + "port": 3306, + "driver": "MySQL", + "name": "Praxis Creutzburg", + "database": "k25330_pracreutz", + "username": "k25330_pracreutz" + } + ] +} \ No newline at end of file diff --git a/Praxis Creutzburg.session.sql b/Praxis Creutzburg.session.sql new file mode 100644 index 0000000..5bf6ad1 --- /dev/null +++ b/Praxis Creutzburg.session.sql @@ -0,0 +1,24 @@ +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; + +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; diff --git a/admin/sql/2026-03-30_zeiterfassung_time_error_notifications.sql b/admin/sql/2026-03-30_zeiterfassung_time_error_notifications.sql new file mode 100644 index 0000000..5bf6ad1 --- /dev/null +++ b/admin/sql/2026-03-30_zeiterfassung_time_error_notifications.sql @@ -0,0 +1,24 @@ +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; + +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; diff --git a/zeiterfassung/inc/time_error_notifications.inc.php b/zeiterfassung/inc/time_error_notifications.inc.php new file mode 100644 index 0000000..dcc0f23 --- /dev/null +++ b/zeiterfassung/inc/time_error_notifications.inc.php @@ -0,0 +1,501 @@ +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 timeErrorNotificationsBuildEmployeeMail(array $employee, string $stage): array + { + $name = trim($employee['vorname'] . ' ' . $employee['nachname']); + $dateList = timeErrorNotificationsFormatDateList($employee['error_dates']); + $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.

'; + + 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']); + $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.

'; + + 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; + } +} diff --git a/zeiterfassung/sendTimeErrorNotifications.php b/zeiterfassung/sendTimeErrorNotifications.php new file mode 100644 index 0000000..c1d51a9 --- /dev/null +++ b/zeiterfassung/sendTimeErrorNotifications.php @@ -0,0 +1,54 @@ + +
+
+
+

Zeiterfassungs-Benachrichtigungen

+ +
+
+
+