Files
awo-hamburg-intranet/module/tasks/service/EscalationService.php
Moritz Weinmann 743476f64a Module tasks + knowledgecenter_update aus Kundenprojekt übernehmen (bereinigt)
Beide Module wurden aus einem anderen Kundenprojekt kopiert und bereinigt:

Knowledgecenter:
- Bild-Cache (1.985 Dateien) geleert, Ordnerstruktur mit .gitkeep behalten
- Files-Gallery-Lizenzschlüssel entfernt (config.php)
- Kundenspezifische Kategorien gelöscht: Teil I/II/III QM-Struktur,
  nummerierte QM-Kapitel, Gremien (Präsidium/Finanzausschuss/Landesausschuss),
  Sitzungsservice-Serie, Protokoll-Abteilungen, Testeinträge
- 56 generische Kategorien bleiben (Downloads, Onboarding, Personalthemen …)

Tasks:
- Alle Betriebsdaten gelöscht (114 Tasks, 914 Submissions, 579 Assignments)
- Task-Definitionen und Verlinkungen geleert
- Kategoriestruktur bleibt (Allgemein, IT, Personal, Buchhaltung …)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 09:20:02 +02:00

276 lines
9.9 KiB
PHP

<?php
declare(strict_types=1);
class EscalationService
{
private TaskAssignmentRepository $assignmentRepo;
private TaskRuleRepository $ruleRepo;
private TaskChangeLogRepository $changeLogRepo;
public function __construct(
TaskAssignmentRepository $assignmentRepo,
TaskRuleRepository $ruleRepo,
TaskChangeLogRepository $changeLogRepo
) {
$this->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;
}
}