ruleRepo = $ruleRepo; $this->assignmentRepo = $assignmentRepo; $this->submissionRepo = $submissionRepo; $this->certificateService = $certificateService; $this->definitionRepo = $definitionRepo; $this->assignmentResolverService = $assignmentResolverService; } public function submitRead(int $assignmentId, int $methodId, array $postIds): int { $context = $this->validateContext($assignmentId, $methodId, 'knowledge_read'); $requiredPostIds = []; foreach ($context['method']['knowledge_posts'] as $post) { if ((int) ($post['required'] ?? 1) !== 1) { continue; } $requiredPostIds[] = (int) $post['knowledge_post_id']; } $submittedPostIds = array_values(array_unique(array_map('intval', $postIds))); $missingPosts = array_values(array_diff($requiredPostIds, $submittedPostIds)); if ($missingPosts !== []) { $submissionId = $this->submissionRepo->create( $assignmentId, $methodId, $context['user_id'], 'submitted', [ 'post_ids' => $submittedPostIds, 'missing_post_ids' => $missingPosts, 'is_complete' => false, ] ); $this->finalizeAssignmentState($assignmentId, $submissionId); return $submissionId; } $submissionId = $this->submissionRepo->create( $assignmentId, $methodId, $context['user_id'], 'auto_passed', [ 'post_ids' => $submittedPostIds, 'missing_post_ids' => [], 'is_complete' => true, ] ); $this->finalizeAssignmentState($assignmentId, $submissionId); return $submissionId; } public function submitForm(int $assignmentId, int $methodId, array $values, array $files = []): int { $context = $this->validateContext($assignmentId, $methodId, 'form_submit'); // Datei-Werte der letzten Einreichung — beim erneuten Absenden ohne // Neu-Upload werden bestehende Dateien übernommen, statt ein Pflicht- // Upload-Feld fälschlich als leer zu werten. $previousFileValues = []; $previous = $this->submissionRepo->findLatestByAssignmentAndMethod($assignmentId, $methodId); if ($previous !== null) { $prevPayload = is_array($previous['payload'] ?? null) ? $previous['payload'] : []; $prevValues = is_array($prevPayload['form_values'] ?? null) ? $prevPayload['form_values'] : []; foreach ($prevValues as $prevKey => $prevValue) { if ($this->isFileValueArray($prevValue)) { $previousFileValues[(string) $prevKey] = $prevValue; } } } $errors = []; $normalizedValues = []; $fileFields = []; foreach ($context['method']['form_fields'] as $field) { $key = (string) $field['field_key']; $fieldType = (string) ($field['field_type'] ?? 'text'); $required = (int) ($field['is_required'] ?? 0) === 1; if ($fieldType === 'file') { $fileFields[$key] = $field; $hasNew = $this->hasUploadedFile($files[$key] ?? []); $hasPrevious = isset($previousFileValues[$key]); if ($required && !$hasNew && !$hasPrevious) { $errors[] = 'Feld erforderlich: ' . $field['label']; } continue; } $value = $values[$key] ?? null; if (is_array($value)) { $normalizedValues[$key] = array_values($value); if ($required && $normalizedValues[$key] === []) { $errors[] = 'Feld erforderlich: ' . $field['label']; } continue; } $valueString = trim((string) $value); $normalizedValues[$key] = $valueString; if ($required && $valueString === '') { $errors[] = 'Feld erforderlich: ' . $field['label']; } } if ($errors !== []) { throw new RuntimeException(implode(' ', $errors)); } // Erst nach erfolgreicher Validierung Dateien verschieben, damit bei // fehlenden Pflichtfeldern keine Dateien persistiert werden. foreach ($fileFields as $key => $field) { $stored = TaskUploadStorage::storeFiles( $assignmentId, $methodId, $key, $files[$key] ?? [] ); // Kein neuer Upload → bestehende Dateien der letzten Einreichung // übernehmen (gleiche Datei-Referenzen). if ($stored === [] && isset($previousFileValues[$key])) { $stored = $previousFileValues[$key]; } $normalizedValues[$key] = $stored; } $submissionId = $this->submissionRepo->create( $assignmentId, $methodId, $context['user_id'], 'auto_passed', ['form_values' => $normalizedValues] ); $this->finalizeAssignmentState($assignmentId, $submissionId); return $submissionId; } private function hasUploadedFile(array $fieldFiles): bool { foreach ($fieldFiles as $file) { if ((int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { return true; } } return false; } private function isFileValueArray($value): bool { if (!is_array($value) || $value === []) { return false; } $first = reset($value); return is_array($first) && array_key_exists('file_path', $first); } public function submitQuiz(int $assignmentId, int $methodId, array $answers): int { $context = $this->validateContext($assignmentId, $methodId, 'quiz'); $score = 0; $maxScore = 0; foreach ($context['method']['quiz_questions'] as $question) { $questionId = (int) $question['id']; $correctOptionIds = []; foreach ($question['options'] as $option) { if ((int) ($option['is_correct'] ?? 0) === 1) { $correctOptionIds[] = (int) $option['id']; } } $submitted = $answers[$questionId] ?? []; if (!is_array($submitted)) { $submitted = [$submitted]; } $submittedIds = array_values(array_unique(array_map('intval', $submitted))); sort($submittedIds); sort($correctOptionIds); $maxScore++; if ($submittedIds === $correctOptionIds) { $score++; } } $status = ($maxScore > 0 && $score === $maxScore) ? 'auto_passed' : 'rejected'; $submissionId = $this->submissionRepo->create( $assignmentId, $methodId, $context['user_id'], $status, [ 'answers' => $answers, 'score' => $score, 'max_score' => $maxScore, ], $score, $maxScore ); $this->finalizeAssignmentState($assignmentId, $submissionId); return $submissionId; } public function requestManualApproval(int $assignmentId, int $methodId, string $note): int { $context = $this->validateContext($assignmentId, $methodId, 'manual_approval'); $existingPending = $this->submissionRepo->findLatestByAssignmentAndMethodAndStatuses( $assignmentId, $methodId, ['awaiting_approval'] ); if ($existingPending !== null) { return (int) $existingPending['id']; } $existingApproved = $this->submissionRepo->findLatestByAssignmentAndMethodAndStatuses( $assignmentId, $methodId, ['approved', 'auto_passed'] ); if ($existingApproved !== null) { return (int) $existingApproved['id']; } $submissionId = $this->submissionRepo->create( $assignmentId, $methodId, $context['user_id'], 'awaiting_approval', [ 'note' => trim($note), ] ); $assignment = $context['assignment']; $currentStatus = (string) ($assignment['status'] ?? ''); if ($currentStatus === 'completed') { return $submissionId; } $dueAtRaw = trim((string) ($assignment['due_at'] ?? '')); $isOverdue = false; if ($dueAtRaw !== '') { try { $isOverdue = (new DateTimeImmutable()) > new DateTimeImmutable($dueAtRaw); } catch (Throwable $throwable) { $isOverdue = false; } } if ($isOverdue) { $this->assignmentRepo->markOverdue($assignmentId, (new DateTimeImmutable())->format('Y-m-d H:i:s')); } else { $this->assignmentRepo->updateStatus($assignmentId, 'approval_pending', null); } return $submissionId; } public function refreshAssignmentAfterReview(int $assignmentId, int $triggerSubmissionId): void { $this->finalizeAssignmentState($assignmentId, $triggerSubmissionId); } private function validateContext(int $assignmentId, int $methodId, string $expectedMethodType): array { $auth = TaskCertAuthContext::fromGlobals(); if (!$auth->isLoggedIn()) { throw new RuntimeException('Nicht eingeloggt'); } $assignment = $this->assignmentRepo->findById($assignmentId); if ($assignment === null) { throw new RuntimeException('Assignment nicht gefunden'); } if ((int) $assignment['main_contact_id'] !== $auth->contactId()) { throw new RuntimeException('Keine Berechtigung für dieses Assignment'); } $assignmentStatus = (string) ($assignment['status'] ?? ''); if ($assignmentStatus === 'completed' || $assignmentStatus === 'expired') { throw new RuntimeException('Diese Aufgabe wurde bereits abgeschlossen und kann nicht erneut eingereicht werden.'); } $method = $this->ruleRepo->findMethodById($methodId); if ($method === null) { throw new RuntimeException('Methode nicht gefunden'); } if ((int) $method['task_id'] !== (int) $assignment['task_id']) { throw new RuntimeException('Methode gehört nicht zur Aufgabe'); } if ((string) $method['method_type'] !== $expectedMethodType) { throw new RuntimeException('Ungültiger Methodentyp'); } $methods = $this->ruleRepo->getMethodsByTask((int) $assignment['task_id']); $methodWithDetails = null; foreach ($methods as $candidate) { if ((int) $candidate['id'] === $methodId) { $methodWithDetails = $candidate; break; } } if ($methodWithDetails === null) { throw new RuntimeException('Methodendetails konnten nicht geladen werden'); } return [ 'user_id' => $auth->contactId(), 'assignment' => $assignment, 'method' => $methodWithDetails, ]; } private function finalizeAssignmentState(int $assignmentId, int $submissionId): void { $assignment = $this->assignmentRepo->findById($assignmentId); if ($assignment === null) { return; } $task = $this->definitionRepo->findById((int) $assignment['task_id']); if ($task === null) { return; } $methods = array_values(array_filter( $this->ruleRepo->getMethodsByTask((int) $assignment['task_id']), static fn(array $method): bool => (int) ($method['active'] ?? 1) === 1 )); $fulfillmentMode = (string) ($task['fulfillment_mode'] ?? 'all'); if (!in_array($fulfillmentMode, ['all', 'any'], true)) { $fulfillmentMode = 'all'; } $fulfilledCount = 0; $requiredCount = 0; foreach ($methods as $method) { $isRequired = (int) ($method['is_required'] ?? 1) === 1; if (!$isRequired) { continue; } $requiredCount++; $isFulfilled = $this->submissionRepo->hasApprovedOrAutoPassedForMethod( $assignmentId, (int) $method['id'] ); if ($isFulfilled) { $fulfilledCount++; } } $done = false; if ($requiredCount === 0) { $done = true; } elseif ($fulfillmentMode === 'all') { $done = $fulfilledCount === $requiredCount; } elseif ($fulfillmentMode === 'any') { $done = $fulfilledCount > 0; } if ($done) { $now = (new DateTimeImmutable())->format('Y-m-d H:i:s'); $this->assignmentRepo->updateStatus($assignmentId, 'completed', $now); $this->assignmentRepo->clearEscalation($assignmentId); $this->certificateService->issueForAssignment($assignmentId, $submissionId); $this->materializeNextCycleIfApplicable((int) $assignment['task_id']); return; } $currentStatus = (string) $assignment['status']; if ($currentStatus !== 'approval_pending') { $this->assignmentRepo->markInProgressIfOpen($assignmentId); } } private function materializeNextCycleIfApplicable(int $taskId): void { if ($this->assignmentResolverService === null) { return; } $task = $this->definitionRepo->findById($taskId); if ($task === null) { return; } $frequencyType = (string) ($task['frequency_type'] ?? ''); $recurrenceBasis = (string) ($task['recurrence_basis'] ?? ''); if ($frequencyType !== 'recurring' || $recurrenceBasis !== 'completion') { return; } try { $this->assignmentResolverService->materializeAssignmentsForTask($taskId); } catch (Throwable $e) { // Silently fail — cron will pick it up on next run } } }