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; } }