definitionRepo = $definitionRepo; $this->ruleRepo = $ruleRepo; $this->assignmentRepo = $assignmentRepo; $this->submissionRepo = $submissionRepo; $this->certificateRepo = $certificateRepo; $this->cycleCalculator = $cycleCalculator; $this->changeLogRepo = $changeLogRepo; } public function materializeAssignmentsForTask(int $taskId, ?string $cycleKey = null): int { $task = $this->definitionRepo->findById($taskId); if ($task === null || (int) $task['active'] !== 1) { return 0; } if ($this->isCompletionBasedRecurringTask($task)) { return $this->materializeCompletionBasedAssignmentsForTask($task, new DateTimeImmutable()); } $cycle = $this->cycleCalculator->resolveCurrentCycle($task, new DateTimeImmutable()); if ($cycle === []) { return 0; } if ($cycleKey !== null && trim($cycleKey) !== '') { $cycle['cycle_key'] = trim($cycleKey); } $resolvedCycleKey = (string) ($cycle['cycle_key'] ?? ''); if ($resolvedCycleKey === '') { return 0; } $dueAt = $this->cycleCalculator->resolveDueAt($task, $cycle); $cycleStartAt = $this->formatDateTime($cycle['cycle_start_at'] ?? null); $cycleEndAt = $this->formatDateTime($cycle['cycle_end_at'] ?? null); $targets = $this->resolveTargets((int) $task['id']); $graceUntil = $this->calculateGraceUntil((int) $task['id']); $inserted = 0; foreach ($targets as $contactId => $source) { $assignmentId = $this->assignmentRepo->createIfNotExists( (int) $task['id'], (int) $contactId, $resolvedCycleKey, $source, $cycleStartAt, $cycleEndAt, $dueAt, $graceUntil ); if ($assignmentId > 0) { $inserted++; } } return $inserted; } public function materializeAssignmentsForAllActiveTasks(): int { $tasks = $this->definitionRepo->listActive(); $total = 0; foreach ($tasks as $task) { $total += $this->materializeAssignmentsForTask((int) $task['id']); } return $total; } public function syncAssignmentsForTask( int $taskId, bool $apply, string $scope = 'current_cycle', array $auditContext = [] ): array { if ($scope !== 'current_cycle') { throw new InvalidArgumentException('Ungültiger Scope. V1 unterstützt nur current_cycle.'); } $task = $this->definitionRepo->findById($taskId); if ($task === null || (int) ($task['active'] ?? 0) !== 1) { return $this->buildSyncStats( $taskId, '', 0, 0, 0, 0, 0, 0, 0, 0, 0, $apply ); } if ($this->isCompletionBasedRecurringTask($task)) { return $this->syncCompletionBasedAssignmentsForTask($task, $apply, $auditContext); } $cycle = $this->cycleCalculator->resolveCurrentCycle($task, new DateTimeImmutable()); $cycleKey = trim((string) ($cycle['cycle_key'] ?? '')); if ($cycleKey === '') { return $this->buildSyncStats( $taskId, '', 0, 0, 0, 0, 0, 0, 0, 0, 0, $apply ); } $dueAt = $this->cycleCalculator->resolveDueAt($task, $cycle); $cycleStartAt = $this->formatDateTime($cycle['cycle_start_at'] ?? null); $cycleEndAt = $this->formatDateTime($cycle['cycle_end_at'] ?? null); $targets = $this->resolveTargets((int) $task['id']); $graceUntil = $this->calculateGraceUntil($taskId); $existingAssignments = $this->assignmentRepo->listByTaskAndCycleKey($taskId, $cycleKey); $existingByContact = []; foreach ($existingAssignments as $assignment) { $contactId = (int) ($assignment['main_contact_id'] ?? 0); if ($contactId <= 0) { continue; } $existingByContact[$contactId] = $assignment; } $targetContactIds = array_values(array_unique(array_map('intval', array_keys($targets)))); $targetContactIds = array_values(array_filter($targetContactIds, static function (int $contactId): bool { return $contactId > 0; })); $createContactIds = []; $keepContactIds = []; foreach ($targetContactIds as $contactId) { if (isset($existingByContact[$contactId])) { $keepContactIds[] = $contactId; continue; } $createContactIds[] = $contactId; } $staleAssignments = []; foreach ($existingByContact as $contactId => $assignment) { if (isset($targets[$contactId])) { continue; } $staleAssignments[] = $assignment; } $staleAssignmentIds = $this->collectAssignmentIds($staleAssignments); $protectedCompletedMap = []; foreach ($staleAssignments as $assignment) { $assignmentId = (int) ($assignment['id'] ?? 0); if ($assignmentId <= 0) { continue; } if ((string) ($assignment['status'] ?? '') === 'completed') { $protectedCompletedMap[$assignmentId] = true; } } $protectedCertificateIds = $this->certificateRepo->listAssignmentIdsWithCertificates($staleAssignmentIds); $protectedCertificateMap = array_fill_keys($protectedCertificateIds, true); $removeAssignmentIds = []; foreach ($staleAssignmentIds as $assignmentId) { if (isset($protectedCompletedMap[$assignmentId]) || isset($protectedCertificateMap[$assignmentId])) { continue; } $removeAssignmentIds[] = $assignmentId; } $removeWithSubmissionsIds = $this->submissionRepo->listAssignmentIdsWithSubmissions($removeAssignmentIds); $removeWithSubmissionsMap = array_fill_keys($removeWithSubmissionsIds, true); $stats = $this->buildSyncStats( $taskId, $cycleKey, count($targetContactIds), count($existingByContact), count($createContactIds), count($keepContactIds), count($staleAssignmentIds), count($protectedCompletedMap), count($protectedCertificateMap), count($removeAssignmentIds), count($removeWithSubmissionsMap), $apply ); if (!$apply) { return $stats; } TaskCertDb::beginTransaction(); try { foreach ($targetContactIds as $contactId) { $source = (string) ($targets[$contactId] ?? 'rule'); if (!in_array($source, ['rule', 'override_include', 'admin_manual'], true)) { $source = 'rule'; } $this->assignmentRepo->createIfNotExists( $taskId, $contactId, $cycleKey, $source, $cycleStartAt, $cycleEndAt, $dueAt, $graceUntil ); } $this->assignmentRepo->deleteByIds($removeAssignmentIds); $auth = TaskCertAuthContext::fromGlobals(); $changedBy = $auth->isLoggedIn() ? $auth->contactId() : 0; if (array_key_exists('changed_by', $auditContext)) { $changedBy = max(0, (int) $auditContext['changed_by']); } $diff = $stats; $trigger = trim((string) ($auditContext['trigger'] ?? (PHP_SAPI === 'cli' ? 'cron_cli' : 'admin_ui'))); if ($trigger !== '') { $diff['trigger'] = $trigger; } $jobRunId = trim((string) ($auditContext['job_run_id'] ?? '')); if ($jobRunId !== '') { $diff['job_run_id'] = $jobRunId; } $this->changeLogRepo->create( $taskId, 'sync_assignments_apply', $changedBy, null, null, $diff ); TaskCertDb::commit(); } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } return $stats; } public function syncAssignmentsForAllActiveTasks( bool $apply, string $scope = 'current_cycle', array $auditContext = [] ): array { if ($scope !== 'current_cycle') { throw new InvalidArgumentException('Ungültiger Scope. V1 unterstützt nur current_cycle.'); } $results = []; foreach ($this->definitionRepo->listActive() as $task) { $taskId = (int) ($task['id'] ?? 0); if ($taskId <= 0) { continue; } $results[] = $this->syncAssignmentsForTask($taskId, $apply, $scope, $auditContext); } return $results; } private function isCompletionBasedRecurringTask(array $task): bool { return (string) ($task['frequency_type'] ?? 'once') === 'recurring' && (string) ($task['recurrence_basis'] ?? 'calendar') === 'completion'; } private function materializeCompletionBasedAssignmentsForTask(array $task, DateTimeImmutable $now): int { $taskId = (int) ($task['id'] ?? 0); if ($taskId <= 0) { return 0; } $targets = $this->resolveTargets($taskId); if ($targets === []) { return 0; } $targetContactIds = array_values(array_unique(array_map('intval', array_keys($targets)))); $targetContactIds = array_values(array_filter($targetContactIds, static function (int $contactId): bool { return $contactId > 0; })); if ($targetContactIds === []) { return 0; } $latestAssignments = $this->assignmentRepo->listLatestByTaskForContacts($taskId, $targetContactIds); $latestByContact = []; foreach ($latestAssignments as $assignment) { $contactId = (int) ($assignment['main_contact_id'] ?? 0); if ($contactId > 0) { $latestByContact[$contactId] = $assignment; } } $graceUntil = $this->calculateGraceUntil($taskId); $inserted = 0; foreach ($targetContactIds as $contactId) { $source = (string) ($targets[$contactId] ?? 'rule'); if (!in_array($source, ['rule', 'override_include', 'admin_manual'], true)) { $source = 'rule'; } $cycle = []; if (!isset($latestByContact[$contactId])) { $cycle = $this->cycleCalculator->resolveInitialCompletionCycle($task, $now); } else { $latest = $latestByContact[$contactId]; if ((string) ($latest['status'] ?? '') !== 'completed') { continue; } $cycle = $this->cycleCalculator->resolveNextCompletionCycle($task, $latest); } if ($cycle === [] || !($cycle['cycle_start_at'] ?? null) instanceof DateTimeImmutable) { continue; } /** @var DateTimeImmutable $cycleStartAt */ $cycleStartAt = $cycle['cycle_start_at']; if ($cycleStartAt > $now) { continue; } $resolvedCycleKey = trim((string) ($cycle['cycle_key'] ?? '')); if ($resolvedCycleKey === '') { continue; } $dueAt = $this->cycleCalculator->resolveDueAt($task, $cycle); $assignmentId = $this->assignmentRepo->createIfNotExists( $taskId, $contactId, $resolvedCycleKey, $source, $this->formatDateTime($cycle['cycle_start_at'] ?? null), $this->formatDateTime($cycle['cycle_end_at'] ?? null), $dueAt, $graceUntil ); if ($assignmentId > 0) { $inserted++; } } return $inserted; } private function syncCompletionBasedAssignmentsForTask( array $task, bool $apply, array $auditContext = [] ): array { $taskId = (int) ($task['id'] ?? 0); if ($taskId <= 0) { return $this->buildSyncStats(0, 'rolling', 0, 0, 0, 0, 0, 0, 0, 0, 0, $apply); } $now = new DateTimeImmutable(); $targets = $this->resolveTargets($taskId); $targetContactIds = array_values(array_unique(array_map('intval', array_keys($targets)))); $targetContactIds = array_values(array_filter($targetContactIds, static function (int $contactId): bool { return $contactId > 0; })); $latestAssignments = $this->assignmentRepo->listLatestByTaskForContacts($taskId, $targetContactIds); $latestByContact = []; foreach ($latestAssignments as $assignment) { $contactId = (int) ($assignment['main_contact_id'] ?? 0); if ($contactId > 0) { $latestByContact[$contactId] = $assignment; } } $createPlans = []; foreach ($targetContactIds as $contactId) { $cycle = []; if (!isset($latestByContact[$contactId])) { $cycle = $this->cycleCalculator->resolveInitialCompletionCycle($task, $now); } else { $latest = $latestByContact[$contactId]; if ((string) ($latest['status'] ?? '') !== 'completed') { continue; } $cycle = $this->cycleCalculator->resolveNextCompletionCycle($task, $latest); } if ($cycle === [] || !($cycle['cycle_start_at'] ?? null) instanceof DateTimeImmutable) { continue; } /** @var DateTimeImmutable $cycleStartAt */ $cycleStartAt = $cycle['cycle_start_at']; if ($cycleStartAt > $now) { continue; } $resolvedCycleKey = trim((string) ($cycle['cycle_key'] ?? '')); if ($resolvedCycleKey === '') { continue; } $source = (string) ($targets[$contactId] ?? 'rule'); if (!in_array($source, ['rule', 'override_include', 'admin_manual'], true)) { $source = 'rule'; } $createPlans[] = [ 'contact_id' => $contactId, 'source' => $source, 'cycle' => $cycle, ]; } $allAssignments = $this->assignmentRepo->listByTask($taskId); $staleContactMap = []; foreach ($allAssignments as $assignment) { $contactId = (int) ($assignment['main_contact_id'] ?? 0); if ($contactId <= 0 || isset($targets[$contactId])) { continue; } $staleContactMap[$contactId] = true; } $staleContactIds = array_keys($staleContactMap); $staleAssignments = $this->assignmentRepo->listRemovableOpenByTaskAndContacts($taskId, $staleContactIds); $staleAssignmentIds = $this->collectAssignmentIds($staleAssignments); $protectedCompletedMap = []; foreach ($staleAssignments as $assignment) { $assignmentId = (int) ($assignment['id'] ?? 0); if ($assignmentId <= 0) { continue; } if ((string) ($assignment['status'] ?? '') === 'completed') { $protectedCompletedMap[$assignmentId] = true; } } $protectedCertificateIds = $this->certificateRepo->listAssignmentIdsWithCertificates($staleAssignmentIds); $protectedCertificateMap = array_fill_keys($protectedCertificateIds, true); $removeAssignmentIds = []; foreach ($staleAssignmentIds as $assignmentId) { if (isset($protectedCompletedMap[$assignmentId]) || isset($protectedCertificateMap[$assignmentId])) { continue; } $removeAssignmentIds[] = $assignmentId; } $removeWithSubmissionsIds = $this->submissionRepo->listAssignmentIdsWithSubmissions($removeAssignmentIds); $removeWithSubmissionsMap = array_fill_keys($removeWithSubmissionsIds, true); $stats = $this->buildSyncStats( $taskId, 'rolling', count($targetContactIds), count($latestByContact), count($createPlans), count($latestByContact), count($staleAssignmentIds), count($protectedCompletedMap), count($protectedCertificateMap), count($removeAssignmentIds), count($removeWithSubmissionsMap), $apply ); if (!$apply) { return $stats; } TaskCertDb::beginTransaction(); try { foreach ($createPlans as $createPlan) { $cycle = is_array($createPlan['cycle'] ?? null) ? $createPlan['cycle'] : []; $resolvedCycleKey = trim((string) ($cycle['cycle_key'] ?? '')); if ($resolvedCycleKey === '') { continue; } $dueAt = $this->cycleCalculator->resolveDueAt($task, $cycle); $this->assignmentRepo->createIfNotExists( $taskId, (int) ($createPlan['contact_id'] ?? 0), $resolvedCycleKey, (string) ($createPlan['source'] ?? 'rule'), $this->formatDateTime($cycle['cycle_start_at'] ?? null), $this->formatDateTime($cycle['cycle_end_at'] ?? null), $dueAt ); } $this->assignmentRepo->deleteByIds($removeAssignmentIds); $auth = TaskCertAuthContext::fromGlobals(); $changedBy = $auth->isLoggedIn() ? $auth->contactId() : 0; if (array_key_exists('changed_by', $auditContext)) { $changedBy = max(0, (int) $auditContext['changed_by']); } $diff = $stats; $trigger = trim((string) ($auditContext['trigger'] ?? (PHP_SAPI === 'cli' ? 'cron_cli' : 'admin_ui'))); if ($trigger !== '') { $diff['trigger'] = $trigger; } $jobRunId = trim((string) ($auditContext['job_run_id'] ?? '')); if ($jobRunId !== '') { $diff['job_run_id'] = $jobRunId; } $this->changeLogRepo->create( $taskId, 'sync_assignments_apply', $changedBy, null, null, $diff ); TaskCertDb::commit(); } catch (Throwable $throwable) { TaskCertDb::rollback(); throw $throwable; } return $stats; } private function resolveTargets(int $taskId): array { $rules = array_values(array_filter( $this->ruleRepo->getTargetRulesByTask($taskId), static fn(array $rule): bool => (int) ($rule['active'] ?? 1) === 1 )); $overrides = $this->ruleRepo->getTargetOverridesByTask($taskId); $scopes = $this->loadActiveContactScopes(); $targets = []; foreach ($scopes as $contactId => $scopeRows) { if ($rules === []) { continue; } if ($this->matchesAnyRule($scopeRows, $rules)) { $targets[$contactId] = 'rule'; } } foreach ($overrides as $override) { $contactId = (int) ($override['main_contact_id'] ?? 0); $type = (string) ($override['override_type'] ?? ''); if ($contactId <= 0) { continue; } if ($type === 'include') { $targets[$contactId] = 'override_include'; } if ($type === 'exclude') { unset($targets[$contactId]); } } return $targets; } private function loadActiveContactScopes(): array { $rows = TaskCertDb::fetchAll( 'SELECT c.id AS main_contact_id, c.master_mandant_id, l.main_mandant_id, l.main_department_id, l.main_role_id, l.main_einricht_id, l.main_bereich_id, l.active FROM main_contact c LEFT JOIN main_contact_department l ON l.main_contact_id = c.id WHERE c.active = 1' ); $scopes = []; foreach ($rows as $row) { if (isset($row['active']) && $row['active'] !== null && (int) $row['active'] !== 1) { continue; } $contactId = (int) $row['main_contact_id']; if (!isset($scopes[$contactId])) { $scopes[$contactId] = []; } $scopes[$contactId][] = [ 'main_mandant_id' => (int) ($row['main_mandant_id'] ?: $row['master_mandant_id']), 'main_department_id' => (int) ($row['main_department_id'] ?? 0), 'main_role_id' => (int) ($row['main_role_id'] ?? 0), 'main_einricht_id' => (int) ($row['main_einricht_id'] ?? 0), 'main_bereich_id' => (int) ($row['main_bereich_id'] ?? 0), ]; } return $scopes; } private function matchesAnyRule(array $scopeRows, array $rules): bool { foreach ($rules as $rule) { $ruleMandant = (int) ($rule['main_mandant_id'] ?? 0); $ruleDepartment = (int) ($rule['main_department_id'] ?? 0); $ruleRole = (int) ($rule['main_role_id'] ?? 0); $ruleEinricht = (int) ($rule['main_einricht_id'] ?? 0); $ruleFachbereich = (int) ($rule['main_bereich_id'] ?? 0); foreach ($scopeRows as $scope) { $mandantMatch = $ruleMandant === 0 || $ruleMandant === (int) $scope['main_mandant_id']; $departmentMatch = $ruleDepartment === 0 || $ruleDepartment === (int) $scope['main_department_id']; $roleMatch = $ruleRole === 0 || $ruleRole === (int) $scope['main_role_id']; $einrichtMatch = $ruleEinricht === 0 || $ruleEinricht === (int) $scope['main_einricht_id']; $fachbereichMatch = $ruleFachbereich === 0 || $ruleFachbereich === (int) $scope['main_bereich_id']; if ($mandantMatch && $departmentMatch && $roleMatch && $einrichtMatch && $fachbereichMatch) { return true; } } } return false; } private function formatDateTime($value): ?string { if ($value instanceof DateTimeImmutable) { return $value->format('Y-m-d H:i:s'); } if (!is_string($value) || trim($value) === '') { return null; } try { return (new DateTimeImmutable($value))->format('Y-m-d H:i:s'); } catch (Throwable $throwable) { return null; } } private function calculateGraceUntil(int $taskId): ?string { $policy = $this->ruleRepo->getReminderPolicyByTask($taskId); $graceDays = max(0, (int) ($policy['grace_days'] ?? 7)); if ($graceDays <= 0) { return null; } try { $now = new DateTimeImmutable(); return $now->modify('+' . $graceDays . ' days')->format('Y-m-d H:i:s'); } catch (Throwable $throwable) { return null; } } private function collectAssignmentIds(array $assignments): array { $ids = []; foreach ($assignments as $assignment) { $assignmentId = (int) ($assignment['id'] ?? 0); if ($assignmentId <= 0) { continue; } $ids[] = $assignmentId; } $ids = array_values(array_unique($ids)); sort($ids); return $ids; } private function buildSyncStats( int $taskId, string $cycleKey, int $targetCount, int $existingCount, int $createCount, int $updateCount, int $staleCount, int $protectedCompletedCount, int $protectedCertificateCount, int $removeCount, int $removeWithSubmissionsCount, bool $applied ): array { return [ 'task_id' => $taskId, 'cycle_key' => $cycleKey, 'target_count' => $targetCount, 'existing_count' => $existingCount, 'create_count' => $createCount, 'update_count' => $updateCount, 'stale_count' => $staleCount, 'protected_completed_count' => $protectedCompletedCount, 'protected_certificate_count' => $protectedCertificateCount, 'remove_count' => $removeCount, 'remove_with_submissions_count' => $removeWithSubmissionsCount, 'applied' => $applied, ]; } }