assignmentRepo = $assignmentRepo; $this->ruleRepo = $ruleRepo; $this->changeLogRepo = $changeLogRepo; } public function recalculateEscalations(): int { $summary = $this->recalculateEscalationsDetailed(true, null); return (int) ($summary['changed_assignments'] ?? 0); } public function recalculateEscalationsDetailed(bool $apply = true, ?int $taskId = null): array { $now = new DateTimeImmutable(); $auth = TaskCertAuthContext::fromGlobals(); $changedBy = $auth->isLoggedIn() ? $auth->contactId() : 0; $scope = $taskId !== null && $taskId > 0 ? 'single_task' : 'all_active_tasks'; $jobRunId = $this->createJobRunId(); $trigger = PHP_SAPI === 'cli' ? 'cron_cli' : 'admin_ui'; $summary = [ 'apply' => $apply, 'task_id' => $taskId !== null && $taskId > 0 ? $taskId : null, 'scope' => $scope, 'job_run_id' => $jobRunId, 'trigger' => $trigger, 'processed_candidates' => 0, 'changed_assignments' => 0, 'entered_count' => 0, 'cleared_count' => 0, 'tasks' => [], ]; $taskSummary = []; foreach ($this->assignmentRepo->findEscalationCandidates($taskId) as $assignment) { $summary['processed_candidates']++; $taskId = (int) $assignment['task_id']; $dueAtRaw = (string) ($assignment['due_at'] ?? ''); if ($dueAtRaw === '') { continue; } try { $dueAt = new DateTimeImmutable($dueAtRaw); } catch (Throwable $throwable) { continue; } $graceUntilRaw = trim((string) ($assignment['grace_until'] ?? '')); $graceUntil = null; if ($graceUntilRaw !== '') { try { $graceUntil = new DateTimeImmutable($graceUntilRaw); } catch (Throwable $throwable) { $graceUntil = null; } } $effectiveDueAt = ($graceUntil !== null && $graceUntil > $dueAt) ? $graceUntil : $dueAt; $isOverdue = $now > $effectiveDueAt; $assignmentId = (int) $assignment['id']; $currentStatus = (string) ($assignment['status'] ?? ''); $oldLevel = (int) ($assignment['escalation_level'] ?? 0); $assignmentChanged = false; $assignmentEntered = false; $assignmentCleared = false; if (!$isOverdue) { if ($currentStatus === 'overdue') { if ($apply) { $this->assignmentRepo->clearOverdue($assignmentId); } $assignmentChanged = true; } if ($oldLevel !== 0) { if ($apply) { $this->assignmentRepo->clearEscalation($assignmentId); $this->assignmentRepo->addEscalationEvent( $assignmentId, 0, 'cleared', ['reason' => 'due_date_not_reached'] ); } $assignmentChanged = true; $assignmentCleared = true; } $this->collectEscalationStats($summary, $taskSummary, $taskId, $assignmentChanged, $assignmentEntered, $assignmentCleared); continue; } $overdueAtRaw = trim((string) ($assignment['overdue_at'] ?? '')); $overdueAt = $overdueAtRaw !== '' ? new DateTimeImmutable($overdueAtRaw) : $effectiveDueAt; if ($currentStatus !== 'overdue') { $overdueAt = $effectiveDueAt; if ($apply) { $this->assignmentRepo->markOverdue($assignmentId, $overdueAt->format('Y-m-d H:i:s')); } $assignmentChanged = true; } elseif ($overdueAtRaw === '') { $overdueAt = $effectiveDueAt; if ($apply) { $this->assignmentRepo->markOverdue($assignmentId, $overdueAt->format('Y-m-d H:i:s')); } $assignmentChanged = true; } $policy = $this->ruleRepo->getReminderPolicyByTask($taskId); if ($policy === null || (int) ($policy['active'] ?? 1) !== 1) { if ($oldLevel !== 0) { if ($apply) { $this->assignmentRepo->clearEscalation($assignmentId); $this->assignmentRepo->addEscalationEvent( $assignmentId, 0, 'cleared', ['reason' => 'policy_inactive'] ); } $assignmentChanged = true; $assignmentCleared = true; } $this->collectEscalationStats($summary, $taskSummary, $taskId, $assignmentChanged, $assignmentEntered, $assignmentCleared); continue; } $daysOverdue = (int) floor(($now->getTimestamp() - $overdueAt->getTimestamp()) / 86400); $firstDays = max(0, (int) ($policy['first_escalation_days'] ?? 0)); $stepDays = max(1, (int) ($policy['step_days'] ?? 7)); $maxLevel = max(1, (int) ($policy['max_level'] ?? 3)); if ($daysOverdue < $firstDays) { $newLevel = 0; } else { $newLevel = 1 + (int) floor(($daysOverdue - $firstDays) / $stepDays); $newLevel = min($newLevel, $maxLevel); } if ($newLevel !== $oldLevel) { $assignmentChanged = true; if ($newLevel === 0) { if ($apply) { $this->assignmentRepo->clearEscalation($assignmentId); $this->assignmentRepo->addEscalationEvent( $assignmentId, 0, 'cleared', [ 'days_overdue' => $daysOverdue, 'old_level' => $oldLevel, ] ); } $assignmentCleared = true; } else { if ($apply) { $this->assignmentRepo->setEscalationLevel($assignmentId, $newLevel); $this->assignmentRepo->addEscalationEvent( $assignmentId, $newLevel, 'entered', [ 'days_overdue' => $daysOverdue, 'old_level' => $oldLevel, 'new_level' => $newLevel, ] ); } $assignmentEntered = true; } } $this->collectEscalationStats($summary, $taskSummary, $taskId, $assignmentChanged, $assignmentEntered, $assignmentCleared); } $summary['tasks'] = array_values($taskSummary); if (!$apply) { return $summary; } foreach ($summary['tasks'] as $taskStat) { $changedAssignments = (int) ($taskStat['changed_assignments'] ?? 0); if ($changedAssignments <= 0) { continue; } $taskIdForLog = (int) ($taskStat['task_id'] ?? 0); if ($taskIdForLog <= 0) { continue; } $this->changeLogRepo->create( $taskIdForLog, 'recalculate_escalations_apply', $changedBy, null, null, [ 'changed_assignments' => $changedAssignments, 'entered_count' => (int) ($taskStat['entered_count'] ?? 0), 'cleared_count' => (int) ($taskStat['cleared_count'] ?? 0), 'scope' => $scope, 'trigger' => $trigger, 'job_run_id' => $jobRunId, ] ); } return $summary; } private function collectEscalationStats( array &$summary, array &$taskSummary, int $taskId, bool $assignmentChanged, bool $assignmentEntered, bool $assignmentCleared ): void { if ($taskId <= 0) { return; } if (!isset($taskSummary[$taskId])) { $taskSummary[$taskId] = [ 'task_id' => $taskId, 'changed_assignments' => 0, 'entered_count' => 0, 'cleared_count' => 0, ]; } if ($assignmentChanged) { $summary['changed_assignments']++; $taskSummary[$taskId]['changed_assignments']++; } if ($assignmentEntered) { $summary['entered_count']++; $taskSummary[$taskId]['entered_count']++; } if ($assignmentCleared) { $summary['cleared_count']++; $taskSummary[$taskId]['cleared_count']++; } } private function createJobRunId(): string { try { $entropy = bin2hex(random_bytes(6)); } catch (Throwable $throwable) { $entropy = substr(sha1((string) microtime(true)), 0, 12); } return 'esc-' . date('YmdHis') . '-' . $entropy; } }