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>
276 lines
9.9 KiB
PHP
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;
|
|
}
|
|
}
|