definitionRepo = $definitionRepo; $this->categoryRepo = $categoryRepo; $this->ruleRepo = $ruleRepo; $this->assignmentRepo = $assignmentRepo; $this->submissionRepo = $submissionRepo; $this->certificateRepo = $certificateRepo; $this->cycleCalculator = $cycleCalculator; $this->assignmentResolver = $assignmentResolver; $this->changeLogRepo = $changeLogRepo; } public function createTask(array $dto): int { $actorId = (int) ($dto['actor_id'] ?? 0); if ($actorId <= 0) { throw new InvalidArgumentException('Missing actor_id for createTask'); } $normalized = $this->normalizeDefinitionData($dto, $actorId, true); TaskCertDb::beginTransaction(); try { $taskId = $this->definitionRepo->create($normalized); $this->ruleRepo->replaceTaskRules($taskId, $dto, $actorId); $afterSnapshot = $this->buildTaskConfigSnapshot($taskId); $this->changeLogRepo->create( $taskId, 'create', $actorId, null, $afterSnapshot, $this->buildSnapshotDiff([], $afterSnapshot) ); TaskCertDb::commit(); } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } $this->assignmentResolver->materializeAssignmentsForTask($taskId); return $taskId; } public function updateTask(int $taskId, array $dto): void { $existing = $this->definitionRepo->findById($taskId); if ($existing === null) { throw new RuntimeException('Aufgabe nicht gefunden'); } $actorId = (int) ($dto['actor_id'] ?? 0); if ($actorId <= 0) { throw new InvalidArgumentException('Missing actor_id for updateTask'); } $normalized = $this->normalizeDefinitionData($dto, $actorId, false, $existing); $beforeSnapshot = $this->buildTaskConfigSnapshot($taskId); TaskCertDb::beginTransaction(); try { $this->definitionRepo->update($taskId, $normalized); $this->ruleRepo->replaceTaskRules($taskId, $dto, $actorId); $afterSnapshot = $this->buildTaskConfigSnapshot($taskId); $this->changeLogRepo->create( $taskId, 'update', $actorId, $beforeSnapshot, $afterSnapshot, $this->buildSnapshotDiff($beforeSnapshot, $afterSnapshot) ); TaskCertDb::commit(); } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } $this->assignmentResolver->materializeAssignmentsForTask($taskId); } public function resetCurrentCycleProgress(int $taskId, int $actorId): array { if ($actorId <= 0) { throw new InvalidArgumentException('Missing actor_id for resetCurrentCycleProgress'); } $task = $this->definitionRepo->findById($taskId); if ($task === null) { throw new RuntimeException('Aufgabe nicht gefunden'); } $cycle = $this->cycleCalculator->resolveCurrentCycle($task, new DateTimeImmutable()); $cycleKey = trim((string) ($cycle['cycle_key'] ?? '')); if ($cycleKey === '') { return [ 'cycle_key' => '', 'assignment_count' => 0, 'submission_count' => 0, 'certificate_count' => 0, ]; } $frequencyType = (string) ($task['frequency_type'] ?? 'once'); $assignments = $frequencyType === 'once' ? $this->assignmentRepo->listByTask($taskId) : $this->assignmentRepo->listByTaskAndCycleKey($taskId, $cycleKey); $assignmentIds = []; foreach ($assignments as $assignment) { $assignmentId = (int) ($assignment['id'] ?? 0); if ($assignmentId > 0) { $assignmentIds[] = $assignmentId; } } $assignmentIds = array_values(array_unique($assignmentIds)); if ($assignmentIds === []) { return [ 'cycle_key' => $cycleKey, 'assignment_count' => 0, 'submission_count' => 0, 'certificate_count' => 0, ]; } // Datei-Pfade vor dem Löschen einsammeln, damit hochgeladene Dateien // nicht als Waisen auf der Platte zurückbleiben. $filePaths = $this->submissionRepo->collectFilePathsByAssignmentIds($assignmentIds); TaskCertDb::beginTransaction(); try { $certificateCount = $this->certificateRepo->deleteByAssignmentIds($assignmentIds); $submissionCount = $this->submissionRepo->deleteByAssignmentIds($assignmentIds); $this->assignmentRepo->resetByIds($assignmentIds); $this->changeLogRepo->create( $taskId, 'reset_cycle_progress', $actorId, null, null, [ 'cycle_key' => $cycleKey, 'frequency_type' => $frequencyType, 'assignment_count' => count($assignmentIds), 'submission_count' => (int) $submissionCount, 'certificate_count' => (int) $certificateCount, ] ); TaskCertDb::commit(); } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } // Erst nach erfolgreichem Commit die Dateien entfernen. TaskUploadStorage::deleteFiles($filePaths); return [ 'cycle_key' => $cycleKey, 'assignment_count' => count($assignmentIds), 'submission_count' => (int) $submissionCount, 'certificate_count' => (int) $certificateCount, ]; } public function resetAssignmentProgress(int $taskId, int $assignmentId, int $actorId): array { if ($actorId <= 0) { throw new InvalidArgumentException('Missing actor_id for resetAssignmentProgress'); } if ($taskId <= 0) { throw new InvalidArgumentException('Ungültige Aufgaben-ID.'); } if ($assignmentId <= 0) { throw new InvalidArgumentException('Ungültige Zuweisungs-ID.'); } $task = $this->definitionRepo->findById($taskId); if ($task === null) { throw new RuntimeException('Aufgabe nicht gefunden'); } $assignment = $this->assignmentRepo->findById($assignmentId); if ($assignment === null) { throw new RuntimeException('Zuweisung nicht gefunden.'); } if ((int) ($assignment['task_id'] ?? 0) !== $taskId) { throw new RuntimeException('Die Zuweisung gehört nicht zur ausgewählten Aufgabe.'); } $assignmentStatus = (string) ($assignment['status'] ?? ''); $nonResettableStatuses = ['expired']; if (in_array($assignmentStatus, $nonResettableStatuses, true)) { throw new RuntimeException('Abgelaufene Zuweisungen können nicht zurückgesetzt werden.'); } if ($assignmentStatus === 'open') { throw new RuntimeException('Diese Zuweisung ist bereits offen und muss nicht zurückgesetzt werden.'); } $frequencyType = (string) ($task['frequency_type'] ?? 'once'); $recurrenceBasis = (string) ($task['recurrence_basis'] ?? 'calendar'); if ($frequencyType === 'recurring') { $contactId = (int) ($assignment['main_contact_id'] ?? 0); if ($contactId <= 0) { throw new RuntimeException('Ungültiger Mitarbeiter in der Zuweisung.'); } if ($recurrenceBasis === 'completion') { $latestAssignment = $this->assignmentRepo->findLatestByTaskAndContact($taskId, $contactId); if ((int) ($latestAssignment['id'] ?? 0) !== $assignmentId) { throw new RuntimeException('Bei erledigungsbasierten Aufgaben kann nur der aktuellste Zeitraum je Mitarbeiter zurückgesetzt werden.'); } } else { // Vergangene Zyklen dürfen zurückgesetzt werden (Admin-Korrektur). } } $assignmentIds = [$assignmentId]; $cycleKey = (string) ($assignment['cycle_key'] ?? ''); // Datei-Pfade vor dem Löschen einsammeln (Aufräumen nach Commit). $filePaths = $this->submissionRepo->collectFilePathsByAssignmentIds($assignmentIds); TaskCertDb::beginTransaction(); try { $certificateCount = $this->certificateRepo->deleteByAssignmentIds($assignmentIds); $submissionCount = $this->submissionRepo->deleteByAssignmentIds($assignmentIds); $this->assignmentRepo->resetByIds($assignmentIds); $this->changeLogRepo->create( $taskId, 'reset_assignment_progress', $actorId, null, null, [ 'assignment_id' => $assignmentId, 'main_contact_id' => (int) ($assignment['main_contact_id'] ?? 0), 'cycle_key' => $cycleKey, 'frequency_type' => (string) ($task['frequency_type'] ?? 'once'), 'recurrence_basis' => $recurrenceBasis, 'assignment_count' => 1, 'submission_count' => (int) $submissionCount, 'certificate_count' => (int) $certificateCount, ] ); TaskCertDb::commit(); } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } // Erst nach erfolgreichem Commit die Dateien entfernen. TaskUploadStorage::deleteFiles($filePaths); return [ 'task_id' => $taskId, 'assignment_id' => $assignmentId, 'cycle_key' => $cycleKey, 'assignment_count' => 1, 'submission_count' => (int) $submissionCount, 'certificate_count' => (int) $certificateCount, ]; } public function adminMarkCompleted( int $taskId, int $assignmentId, int $actorId, array $methodPayloads, string $adminNote ): array { if ($actorId <= 0) { throw new InvalidArgumentException('Missing actor_id for adminMarkCompleted'); } $task = $this->definitionRepo->findById($taskId); if ($task === null) { throw new RuntimeException('Aufgabe nicht gefunden.'); } $assignment = $this->assignmentRepo->findById($assignmentId); if ($assignment === null) { throw new RuntimeException('Zuweisung nicht gefunden.'); } if ((int) ($assignment['task_id'] ?? 0) !== $taskId) { throw new RuntimeException('Die Zuweisung gehört nicht zur ausgewählten Aufgabe.'); } $status = (string) ($assignment['status'] ?? ''); if ($status === 'completed') { throw new RuntimeException('Diese Zuweisung ist bereits abgeschlossen.'); } if ($status === 'expired') { throw new RuntimeException('Abgelaufene Zuweisungen können nicht als erledigt markiert werden.'); } $contactId = (int) ($assignment['main_contact_id'] ?? 0); $cycleKey = (string) ($assignment['cycle_key'] ?? ''); $methods = array_values(array_filter( $this->ruleRepo->getMethodsByTask($taskId), static fn(array $m): bool => (int) ($m['active'] ?? 1) === 1 )); TaskCertDb::beginTransaction(); try { $lastSubmissionId = 0; $submissionCount = 0; foreach ($methods as $method) { $methodId = (int) ($method['id'] ?? 0); $methodType = (string) ($method['method_type'] ?? ''); if ($methodId <= 0) { continue; } $existing = $this->submissionRepo->findLatestByAssignmentAndMethodAndStatuses( $assignmentId, $methodId, ['auto_passed', 'approved'] ); if ($existing !== null) { $lastSubmissionId = (int) $existing['id']; continue; } $payload = $methodPayloads[$methodId] ?? []; $submissionStatus = 'auto_passed'; $score = null; $maxScore = null; if ($methodType === 'knowledge_read') { $postIds = []; foreach ((array) ($method['knowledge_posts'] ?? []) as $post) { $postIds[] = (int) ($post['knowledge_post_id'] ?? 0); } $postIds = array_values(array_filter($postIds, static fn(int $id): bool => $id > 0)); $payload = [ 'post_ids' => $postIds, 'missing_post_ids' => [], 'is_complete' => true, '_admin_completed' => true, ]; } elseif ($methodType === 'form_submit') { $formValues = []; foreach ((array) ($method['form_fields'] ?? []) as $field) { $key = (string) ($field['field_key'] ?? ''); if ($key === '') { continue; } $formValues[$key] = $payload[$key] ?? ''; } $payload = [ 'form_values' => $formValues, '_admin_completed' => true, ]; } elseif ($methodType === 'quiz') { $questionCount = count((array) ($method['quiz_questions'] ?? [])); $score = $questionCount; $maxScore = $questionCount; $payload = [ 'answers' => [], 'score' => $questionCount, 'max_score' => $questionCount, '_admin_completed' => true, ]; } elseif ($methodType === 'manual_approval') { $submissionStatus = 'approved'; $payload = [ 'note' => trim($adminNote) !== '' ? trim($adminNote) : 'Admin-Freigabe', '_admin_completed' => true, ]; } $submissionId = $this->submissionRepo->create( $assignmentId, $methodId, $contactId, $submissionStatus, $payload, $score, $maxScore ); if ($methodType === 'manual_approval') { $this->submissionRepo->review($submissionId, 'approved', $actorId, trim($adminNote)); } $lastSubmissionId = $submissionId; $submissionCount++; } $now = (new DateTimeImmutable())->format('Y-m-d H:i:s'); $this->assignmentRepo->updateStatus($assignmentId, 'completed', $now); $this->assignmentRepo->clearEscalation($assignmentId); $certificateService = new CertificateService( $this->certificateRepo, $this->assignmentRepo, $this->definitionRepo, $this->cycleCalculator ); $certificateId = 0; if ($lastSubmissionId > 0) { $certificateId = $certificateService->issueForAssignment($assignmentId, $lastSubmissionId); } $this->changeLogRepo->create( $taskId, 'admin_mark_completed', $actorId, null, null, [ 'assignment_id' => $assignmentId, 'main_contact_id' => $contactId, 'cycle_key' => $cycleKey, 'submission_count' => $submissionCount, 'certificate_id' => $certificateId, 'admin_note' => trim($adminNote), ] ); TaskCertDb::commit(); } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } return [ 'task_id' => $taskId, 'assignment_id' => $assignmentId, 'cycle_key' => $cycleKey, 'submission_count' => $submissionCount, 'certificate_id' => $certificateId, ]; } public function softDeleteTask(int $taskId): void { $auth = TaskCertAuthContext::fromGlobals(); if (!$auth->isLoggedIn()) { throw new RuntimeException('Nicht eingeloggt'); } $existing = $this->definitionRepo->findById($taskId); if ($existing === null) { throw new RuntimeException('Aufgabe nicht gefunden'); } $actorId = $auth->contactId(); $beforeSnapshot = $this->buildTaskConfigSnapshot($taskId); TaskCertDb::beginTransaction(); try { $this->definitionRepo->softDelete($taskId, $actorId); $afterSnapshot = $this->buildTaskConfigSnapshot($taskId); $this->changeLogRepo->create( $taskId, 'soft_delete', $actorId, $beforeSnapshot, $afterSnapshot, $this->buildSnapshotDiff($beforeSnapshot, $afterSnapshot) ); TaskCertDb::commit(); } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } } public function hardDeleteTask(int $taskId): array { $auth = TaskCertAuthContext::fromGlobals(); if (!$auth->isLoggedIn()) { throw new RuntimeException('Nicht eingeloggt'); } $existing = $this->definitionRepo->findById($taskId); if ($existing === null) { throw new RuntimeException('Aufgabe nicht gefunden'); } $actorId = $auth->contactId(); $beforeSnapshot = $this->buildTaskConfigSnapshot($taskId); $impact = $this->definitionRepo->getHardDeleteImpact($taskId); // Datei-Pfade vor dem CASCADE-Löschen einsammeln (Aufräumen nach Commit). $filePaths = $this->submissionRepo->collectFilePathsByTaskId($taskId); TaskCertDb::beginTransaction(); try { $deletedRows = $this->definitionRepo->hardDelete($taskId); if ($deletedRows <= 0) { throw new RuntimeException('Aufgabe konnte nicht gelöscht werden.'); } $this->changeLogRepo->create( $taskId, 'hard_delete', $actorId, $beforeSnapshot, null, [ 'deleted' => true, 'impact' => $impact, ] ); TaskCertDb::commit(); } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } // Erst nach erfolgreichem Commit die Dateien entfernen. TaskUploadStorage::deleteFiles($filePaths); return $impact; } public function deleteTask(int $taskId): void { $this->softDeleteTask($taskId); } public function listTasksForAdmin(string $searchQuery = '', int $categoryIdFilter = 0): array { return $this->definitionRepo->listForAdmin($searchQuery, $categoryIdFilter); } public function listTasksForResponsible(int $contactId): array { return $this->definitionRepo->listForResponsible($contactId); } public function getTaskDetail(int $taskId): ?array { $task = $this->definitionRepo->findById($taskId); if ($task === null) { return null; } $task['rules'] = $this->ruleRepo->getTaskRuleBundle($taskId); $task['assignment_summary'] = $this->assignmentRepo->listTaskAssignmentSummary($taskId); return $task; } private function normalizeDefinitionData( array $dto, int $actorId, bool $isCreate, array $existing = [] ): array { $title = trim((string) ($dto['title'] ?? ($existing['title'] ?? ''))); if ($title === '') { throw new InvalidArgumentException('Titel ist erforderlich.'); } $taskCategoryId = (int) ($dto['task_category_id'] ?? ($existing['task_category_id'] ?? 0)); if ($taskCategoryId <= 0) { throw new InvalidArgumentException('Bitte Kategorie auswählen.'); } $existingTaskCategoryId = isset($existing['task_category_id']) ? (int) $existing['task_category_id'] : null; $this->ensureCategoryUsableForTask($taskCategoryId, $existingTaskCategoryId); $frequencyType = (string) ($dto['frequency_type'] ?? ($existing['frequency_type'] ?? 'once')); if (!in_array($frequencyType, ['once', 'recurring'], true)) { $frequencyType = 'once'; } $recurrenceBasis = (string) ($dto['recurrence_basis'] ?? ($existing['recurrence_basis'] ?? 'calendar')); if (!in_array($recurrenceBasis, ['calendar', 'completion'], true)) { $recurrenceBasis = 'calendar'; } $recurrenceUnit = (string) ($dto['recurrence_unit'] ?? ($existing['recurrence_unit'] ?? '')); if (!in_array($recurrenceUnit, ['day', 'week', 'month', 'year'], true)) { $recurrenceUnit = null; } $recurrenceInterval = max(1, (int) ($dto['recurrence_interval'] ?? ($existing['recurrence_interval'] ?? 1))); if ($frequencyType === 'once') { $recurrenceBasis = 'calendar'; $recurrenceUnit = null; $recurrenceInterval = null; } elseif ($recurrenceUnit === null) { throw new InvalidArgumentException('Bei wiederkehrenden Aufgaben muss eine Intervall-Einheit ausgewählt werden.'); } $startDate = trim((string) ($dto['start_date'] ?? ($existing['start_date'] ?? date('Y-m-d')))); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { $startDate = date('Y-m-d'); } $deadlineRuleType = (string) ($dto['deadline_rule_type'] ?? ($existing['deadline_rule_type'] ?? 'cycle_end')); if (!in_array($deadlineRuleType, ['cycle_end', 'offset_days', 'weekday', 'monthday'], true)) { $deadlineRuleType = 'cycle_end'; } if ($frequencyType === 'once' && in_array($deadlineRuleType, ['weekday', 'monthday'], true)) { throw new InvalidArgumentException('Für einmalige Aufgaben sind nur Fristen vom Typ "Zyklusende" oder "X Tage" erlaubt.'); } if ($deadlineRuleType === 'weekday' && ($frequencyType !== 'recurring' || $recurrenceUnit !== 'week')) { throw new InvalidArgumentException('Die Fristregel "Wochentag" ist nur für wöchentliche Aufgaben zulässig.'); } if ($deadlineRuleType === 'monthday' && ($frequencyType !== 'recurring' || !in_array($recurrenceUnit, ['month', 'year'], true))) { throw new InvalidArgumentException('Die Fristregel "Monatstag" ist nur für monatliche oder jährliche Aufgaben zulässig.'); } $deadlineOffsetDays = null; $deadlineWeekday = null; $deadlineMonthday = null; if ($deadlineRuleType === 'offset_days') { $deadlineOffsetDays = $this->nullableInt($dto['deadline_offset_days'] ?? ($existing['deadline_offset_days'] ?? null)); if (($deadlineOffsetDays ?? 0) <= 0) { throw new InvalidArgumentException('Bitte eine Frist in Tagen größer 0 angeben.'); } } if ($deadlineRuleType === 'weekday') { $weekday = (int) ($dto['deadline_weekday'] ?? ($existing['deadline_weekday'] ?? 0)); if ($weekday < 1 || $weekday > 7) { throw new InvalidArgumentException('Bitte einen gültigen Wochentag (1-7) wählen.'); } $deadlineWeekday = $weekday; } if ($deadlineRuleType === 'monthday') { $monthday = (int) ($dto['deadline_monthday'] ?? ($existing['deadline_monthday'] ?? 0)); if ($monthday < 1 || $monthday > 31) { throw new InvalidArgumentException('Bitte einen gültigen Monatstag (1-31) wählen.'); } $deadlineMonthday = $monthday; } $fulfillmentMode = (string) ($dto['fulfillment_mode'] ?? ($existing['fulfillment_mode'] ?? 'all')); if (!in_array($fulfillmentMode, ['all', 'any'], true)) { $fulfillmentMode = 'all'; } $certificateEnabled = (int) ($dto['certificate_enabled'] ?? ($existing['certificate_enabled'] ?? 1)) === 1 ? 1 : 0; $certificateTitle = trim((string) ($dto['certificate_title'] ?? ($existing['certificate_title'] ?? ''))); if ($certificateTitle === '') { $certificateTitle = $title; } $certificateValidityMode = (string) ($dto['certificate_validity_mode'] ?? ($existing['certificate_validity_mode'] ?? '')); if (!in_array($certificateValidityMode, ['cycle', 'days', 'permanent'], true)) { $existingValidityDays = (int) ($existing['certificate_validity_days'] ?? 0); if ($existingValidityDays > 0) { $certificateValidityMode = 'days'; } elseif ($frequencyType === 'recurring') { $certificateValidityMode = 'cycle'; } else { $certificateValidityMode = 'permanent'; } } if ($frequencyType !== 'recurring' && $certificateValidityMode === 'cycle') { $certificateValidityMode = 'permanent'; } $certificateValidityDays = null; if ($certificateValidityMode === 'days') { $days = (int) ($dto['certificate_validity_days'] ?? ($existing['certificate_validity_days'] ?? 0)); if ($days <= 0 && $certificateEnabled === 1) { throw new InvalidArgumentException('Bitte Gültigkeit in Tagen größer 0 angeben oder anderen Zertifikatsmodus wählen.'); } if ($days > 0) { $certificateValidityDays = $days; } } $data = [ 'title' => $title, 'description' => trim((string) ($dto['description'] ?? ($existing['description'] ?? ''))), 'task_category_id' => $taskCategoryId, 'frequency_type' => $frequencyType, 'recurrence_basis' => $recurrenceBasis, 'recurrence_unit' => $recurrenceUnit, 'recurrence_interval' => $recurrenceInterval, 'start_date' => $startDate, 'deadline_rule_type' => $deadlineRuleType, 'deadline_offset_days' => $deadlineOffsetDays, 'deadline_weekday' => $deadlineWeekday, 'deadline_monthday' => $deadlineMonthday, 'fulfillment_mode' => $fulfillmentMode, 'certificate_enabled' => $certificateEnabled, 'certificate_title' => $certificateTitle, 'certificate_validity_mode' => $certificateValidityMode, 'certificate_validity_days' => $certificateValidityDays, 'show_cycle_period' => (int) ($dto['show_cycle_period'] ?? ($existing['show_cycle_period'] ?? 0)) === 1 ? 1 : 0, 'active' => (int) ($dto['active'] ?? ($existing['active'] ?? 1)) === 1 ? 1 : 0, 'modified_by' => $actorId, ]; if ($isCreate) { $data['created_by'] = $actorId; } else { $data['created_by'] = (int) ($existing['created_by'] ?? $actorId); } return $data; } private function ensureCategoryUsableForTask(int $taskCategoryId, ?int $existingTaskCategoryId): void { $category = $this->categoryRepo->findById($taskCategoryId); if ($category === null) { throw new InvalidArgumentException('Ausgewählte Kategorie existiert nicht.'); } $isActive = (int) ($category['active'] ?? 0) === 1; if ($isActive) { return; } if ($existingTaskCategoryId !== null && $existingTaskCategoryId > 0 && $existingTaskCategoryId === $taskCategoryId) { return; } throw new InvalidArgumentException('Ausgewählte Kategorie ist inaktiv und kann nicht neu zugewiesen werden.'); } private function buildTaskConfigSnapshot(int $taskId): array { $task = $this->definitionRepo->findById($taskId); if ($task === null) { return []; } $rules = $this->ruleRepo->getTaskRuleBundle($taskId); return [ 'task' => $this->normalizeTaskDefinitionSnapshot($task), 'rules' => [ 'methods' => $this->normalizeMethodsSnapshot( is_array($rules['methods'] ?? null) ? $rules['methods'] : [] ), 'target_rules' => $this->normalizeTargetRulesSnapshot( is_array($rules['target_rules'] ?? null) ? $rules['target_rules'] : [] ), 'target_overrides' => $this->normalizeTargetOverridesSnapshot( is_array($rules['target_overrides'] ?? null) ? $rules['target_overrides'] : [] ), 'reminder_policy' => $this->normalizeReminderPolicySnapshot( is_array($rules['reminder_policy'] ?? null) ? $rules['reminder_policy'] : null ), 'responsible_contact_ids' => $this->normalizeResponsibleContactIdsSnapshot( is_array($rules['responsible_contact_ids'] ?? null) ? $rules['responsible_contact_ids'] : [] ), ], ]; } private function normalizeResponsibleContactIdsSnapshot(array $contactIds): array { $ids = array_values(array_unique(array_filter(array_map('intval', $contactIds), static fn(int $id): bool => $id > 0))); sort($ids, SORT_NUMERIC); return $ids; } private function normalizeTaskDefinitionSnapshot(array $task): array { return [ 'title' => trim((string) ($task['title'] ?? '')), 'description' => trim((string) ($task['description'] ?? '')), 'task_category_id' => (int) ($task['task_category_id'] ?? 0), 'frequency_type' => (string) ($task['frequency_type'] ?? 'once'), 'recurrence_basis' => (string) ($task['recurrence_basis'] ?? 'calendar'), 'recurrence_unit' => ($task['recurrence_unit'] ?? null) !== null ? (string) $task['recurrence_unit'] : null, 'recurrence_interval' => $this->nullableInt($task['recurrence_interval'] ?? null), 'start_date' => (string) ($task['start_date'] ?? ''), 'deadline_rule_type' => (string) ($task['deadline_rule_type'] ?? 'cycle_end'), 'deadline_offset_days' => $this->nullableInt($task['deadline_offset_days'] ?? null), 'deadline_weekday' => $this->nullableInt($task['deadline_weekday'] ?? null), 'deadline_monthday' => $this->nullableInt($task['deadline_monthday'] ?? null), 'fulfillment_mode' => (string) ($task['fulfillment_mode'] ?? 'all'), 'certificate_enabled' => (int) ($task['certificate_enabled'] ?? 0), 'certificate_title' => trim((string) ($task['certificate_title'] ?? '')), 'certificate_validity_mode' => (string) ($task['certificate_validity_mode'] ?? ''), 'certificate_validity_days' => $this->nullableInt($task['certificate_validity_days'] ?? null), 'show_cycle_period' => (int) ($task['show_cycle_period'] ?? 0), 'active' => (int) ($task['active'] ?? 0), ]; } private function normalizeMethodsSnapshot(array $methods): array { $normalized = []; foreach ($methods as $method) { if (!is_array($method)) { continue; } $methodType = trim((string) ($method['method_type'] ?? '')); if ($methodType === '') { continue; } $knowledgePostIds = is_array($method['knowledge_post_ids'] ?? null) ? array_map('intval', $method['knowledge_post_ids']) : []; if ($knowledgePostIds === []) { foreach ((array) ($method['knowledge_posts'] ?? []) as $post) { $postId = (int) (is_array($post) ? ($post['knowledge_post_id'] ?? 0) : $post); if ($postId > 0) { $knowledgePostIds[] = $postId; } } } $knowledgePostIds = array_values(array_unique($knowledgePostIds)); sort($knowledgePostIds, SORT_NUMERIC); $formFieldKeys = is_array($method['form_field_keys'] ?? null) ? array_map('strval', $method['form_field_keys']) : []; if ($formFieldKeys === []) { foreach ((array) ($method['form_fields'] ?? []) as $field) { if (!is_array($field)) { continue; } $fieldKey = trim((string) ($field['field_key'] ?? '')); if ($fieldKey !== '') { $formFieldKeys[] = $fieldKey; } } } $formFieldKeys = array_values(array_filter(array_map('trim', $formFieldKeys), static fn(string $value): bool => $value !== '')); $formFieldKeys = array_values(array_unique($formFieldKeys)); sort($formFieldKeys, SORT_STRING); $quizQuestionCount = (int) ($method['quiz_question_count'] ?? 0); if ($quizQuestionCount <= 0) { $quizQuestionCount = 0; foreach ((array) ($method['quiz_questions'] ?? []) as $question) { if (!is_array($question)) { continue; } $questionText = trim((string) ($question['question_text'] ?? '')); if ($questionText !== '') { $quizQuestionCount++; } } } $normalized[$methodType] = [ 'method_type' => $methodType, 'active' => (int) ($method['active'] ?? 1), 'is_required' => (int) ($method['is_required'] ?? 1), 'sort_order' => (int) ($method['sort_order'] ?? 0), 'settings' => $this->normalizeValueRecursive(is_array($method['settings'] ?? null) ? $method['settings'] : []), 'knowledge_post_ids' => $knowledgePostIds, 'form_field_keys' => $formFieldKeys, 'quiz_question_count' => $quizQuestionCount, ]; } ksort($normalized, SORT_STRING); return array_values($normalized); } private function normalizeTargetRulesSnapshot(array $targetRules): array { $normalized = []; foreach ($targetRules as $rule) { if (!is_array($rule)) { continue; } $entry = [ 'main_mandant_id' => (int) ($rule['main_mandant_id'] ?? 0), 'main_department_id' => (int) ($rule['main_department_id'] ?? 0), 'main_role_id' => (int) ($rule['main_role_id'] ?? 0), 'active' => (int) ($rule['active'] ?? 1), ]; $key = $entry['main_mandant_id'] . ':' . $entry['main_department_id'] . ':' . $entry['main_role_id'] . ':' . $entry['active']; $normalized[$key] = $entry; } ksort($normalized, SORT_STRING); return array_values($normalized); } private function normalizeTargetOverridesSnapshot(array $targetOverrides): array { $normalized = []; foreach ($targetOverrides as $override) { if (!is_array($override)) { continue; } $type = (string) ($override['override_type'] ?? ''); if (!in_array($type, ['include', 'exclude'], true)) { continue; } $contactId = (int) ($override['main_contact_id'] ?? 0); if ($contactId <= 0) { continue; } $key = $type . ':' . $contactId; $normalized[$key] = [ 'override_type' => $type, 'main_contact_id' => $contactId, 'reason' => trim((string) ($override['reason'] ?? '')), ]; } ksort($normalized, SORT_STRING); return array_values($normalized); } private function normalizeReminderPolicySnapshot(?array $policy): array { if (!is_array($policy)) { return []; } return [ 'base_mode' => (string) ($policy['base_mode'] ?? 'due_date'), 'first_escalation_days' => (int) ($policy['first_escalation_days'] ?? 0), 'step_days' => (int) ($policy['step_days'] ?? 0), 'max_level' => (int) ($policy['max_level'] ?? 0), 'active' => (int) ($policy['active'] ?? 1), ]; } private function buildSnapshotDiff(array $beforeSnapshot, array $afterSnapshot): array { $beforeTask = is_array($beforeSnapshot['task'] ?? null) ? $beforeSnapshot['task'] : []; $afterTask = is_array($afterSnapshot['task'] ?? null) ? $afterSnapshot['task'] : []; $beforeRules = is_array($beforeSnapshot['rules'] ?? null) ? $beforeSnapshot['rules'] : []; $afterRules = is_array($afterSnapshot['rules'] ?? null) ? $afterSnapshot['rules'] : []; $beforeTargetRuleMap = $this->buildTargetRuleMap( $this->normalizeTargetRulesSnapshot(is_array($beforeRules['target_rules'] ?? null) ? $beforeRules['target_rules'] : []) ); $afterTargetRuleMap = $this->buildTargetRuleMap( $this->normalizeTargetRulesSnapshot(is_array($afterRules['target_rules'] ?? null) ? $afterRules['target_rules'] : []) ); $beforeTargetOverrideMap = $this->buildTargetOverrideMap( $this->normalizeTargetOverridesSnapshot(is_array($beforeRules['target_overrides'] ?? null) ? $beforeRules['target_overrides'] : []) ); $afterTargetOverrideMap = $this->buildTargetOverrideMap( $this->normalizeTargetOverridesSnapshot(is_array($afterRules['target_overrides'] ?? null) ? $afterRules['target_overrides'] : []) ); $beforeMethodMap = $this->buildMethodMap( $this->normalizeMethodsSnapshot(is_array($beforeRules['methods'] ?? null) ? $beforeRules['methods'] : []) ); $afterMethodMap = $this->buildMethodMap( $this->normalizeMethodsSnapshot(is_array($afterRules['methods'] ?? null) ? $afterRules['methods'] : []) ); $targetRuleAddedKeys = array_values(array_diff(array_keys($afterTargetRuleMap), array_keys($beforeTargetRuleMap))); $targetRuleRemovedKeys = array_values(array_diff(array_keys($beforeTargetRuleMap), array_keys($afterTargetRuleMap))); sort($targetRuleAddedKeys, SORT_STRING); sort($targetRuleRemovedKeys, SORT_STRING); $targetOverrideAddedKeys = array_values(array_diff(array_keys($afterTargetOverrideMap), array_keys($beforeTargetOverrideMap))); $targetOverrideRemovedKeys = array_values(array_diff(array_keys($beforeTargetOverrideMap), array_keys($afterTargetOverrideMap))); sort($targetOverrideAddedKeys, SORT_STRING); sort($targetOverrideRemovedKeys, SORT_STRING); $targetOverridesReasonChanged = []; $targetOverrideCommonKeys = array_values(array_intersect(array_keys($beforeTargetOverrideMap), array_keys($afterTargetOverrideMap))); sort($targetOverrideCommonKeys, SORT_STRING); foreach ($targetOverrideCommonKeys as $key) { $beforeReason = (string) ($beforeTargetOverrideMap[$key]['reason'] ?? ''); $afterReason = (string) ($afterTargetOverrideMap[$key]['reason'] ?? ''); if ($beforeReason === $afterReason) { continue; } $targetOverridesReasonChanged[] = [ 'override_type' => (string) ($afterTargetOverrideMap[$key]['override_type'] ?? ''), 'main_contact_id' => (int) ($afterTargetOverrideMap[$key]['main_contact_id'] ?? 0), 'before_reason' => $beforeReason, 'after_reason' => $afterReason, ]; } $methodAddedKeys = array_values(array_diff(array_keys($afterMethodMap), array_keys($beforeMethodMap))); $methodRemovedKeys = array_values(array_diff(array_keys($beforeMethodMap), array_keys($afterMethodMap))); sort($methodAddedKeys, SORT_STRING); sort($methodRemovedKeys, SORT_STRING); $methodsChanged = []; $methodCommonKeys = array_values(array_intersect(array_keys($beforeMethodMap), array_keys($afterMethodMap))); sort($methodCommonKeys, SORT_STRING); foreach ($methodCommonKeys as $key) { $beforeValue = $this->normalizeValueRecursive($beforeMethodMap[$key]); $afterValue = $this->normalizeValueRecursive($afterMethodMap[$key]); if ($beforeValue === $afterValue) { continue; } $methodsChanged[] = [ 'method_type' => $key, 'before' => $beforeMethodMap[$key], 'after' => $afterMethodMap[$key], ]; } return [ 'task_fields_changed' => $this->buildFieldChanges($beforeTask, $afterTask), 'methods_added' => array_values(array_map(static fn(string $key): array => $afterMethodMap[$key], $methodAddedKeys)), 'methods_removed' => array_values(array_map(static fn(string $key): array => $beforeMethodMap[$key], $methodRemovedKeys)), 'methods_changed' => $methodsChanged, 'target_rules_added' => array_values(array_map(static fn(string $key): array => $afterTargetRuleMap[$key], $targetRuleAddedKeys)), 'target_rules_removed' => array_values(array_map(static fn(string $key): array => $beforeTargetRuleMap[$key], $targetRuleRemovedKeys)), 'target_overrides_added' => array_values(array_map(static fn(string $key): array => $afterTargetOverrideMap[$key], $targetOverrideAddedKeys)), 'target_overrides_removed' => array_values(array_map(static fn(string $key): array => $beforeTargetOverrideMap[$key], $targetOverrideRemovedKeys)), 'target_overrides_reason_changed' => $targetOverridesReasonChanged, 'reminder_policy_changed' => $this->buildFieldChanges( $this->normalizeReminderPolicySnapshot(is_array($beforeRules['reminder_policy'] ?? null) ? $beforeRules['reminder_policy'] : null), $this->normalizeReminderPolicySnapshot(is_array($afterRules['reminder_policy'] ?? null) ? $afterRules['reminder_policy'] : null) ), ]; } private function buildFieldChanges(array $before, array $after): array { $keys = array_values(array_unique(array_merge(array_keys($before), array_keys($after)))); sort($keys, SORT_STRING); $changes = []; foreach ($keys as $key) { $beforeValue = $this->normalizeValueRecursive($before[$key] ?? null); $afterValue = $this->normalizeValueRecursive($after[$key] ?? null); if ($beforeValue === $afterValue) { continue; } $changes[$key] = [ 'before' => $before[$key] ?? null, 'after' => $after[$key] ?? null, ]; } return $changes; } private function buildTargetRuleMap(array $rules): array { $map = []; foreach ($rules as $rule) { if (!is_array($rule)) { continue; } $entry = [ 'main_mandant_id' => (int) ($rule['main_mandant_id'] ?? 0), 'main_department_id' => (int) ($rule['main_department_id'] ?? 0), 'main_role_id' => (int) ($rule['main_role_id'] ?? 0), 'active' => (int) ($rule['active'] ?? 1), ]; $key = $entry['main_mandant_id'] . ':' . $entry['main_department_id'] . ':' . $entry['main_role_id'] . ':' . $entry['active']; $map[$key] = $entry; } ksort($map, SORT_STRING); return $map; } private function buildTargetOverrideMap(array $overrides): array { $map = []; foreach ($overrides as $override) { if (!is_array($override)) { continue; } $type = (string) ($override['override_type'] ?? ''); $contactId = (int) ($override['main_contact_id'] ?? 0); if (!in_array($type, ['include', 'exclude'], true) || $contactId <= 0) { continue; } $key = $type . ':' . $contactId; $map[$key] = [ 'override_type' => $type, 'main_contact_id' => $contactId, 'reason' => trim((string) ($override['reason'] ?? '')), ]; } ksort($map, SORT_STRING); return $map; } private function buildMethodMap(array $methods): array { $map = []; foreach ($methods as $method) { if (!is_array($method)) { continue; } $methodType = trim((string) ($method['method_type'] ?? '')); if ($methodType === '') { continue; } $map[$methodType] = $method; } ksort($map, SORT_STRING); return $map; } private function normalizeValueRecursive($value) { if (!is_array($value)) { return $value; } if ($this->isListArray($value)) { $normalized = []; foreach ($value as $item) { $normalized[] = $this->normalizeValueRecursive($item); } return $normalized; } $normalized = []; $keys = array_keys($value); sort($keys, SORT_STRING); foreach ($keys as $key) { $normalized[$key] = $this->normalizeValueRecursive($value[$key]); } return $normalized; } private function isListArray(array $value): bool { $expected = 0; foreach (array_keys($value) as $key) { if ($key !== $expected) { return false; } $expected++; } return true; } private function nullableInt($value): ?int { if ($value === null || $value === '' || (int) $value <= 0) { return null; } return (int) $value; } }