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>
452 lines
15 KiB
PHP
452 lines
15 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
class CompletionService
|
|
{
|
|
private TaskRuleRepository $ruleRepo;
|
|
private TaskAssignmentRepository $assignmentRepo;
|
|
private TaskSubmissionRepository $submissionRepo;
|
|
private CertificateService $certificateService;
|
|
private TaskDefinitionRepository $definitionRepo;
|
|
private ?AssignmentResolverService $assignmentResolverService;
|
|
|
|
public function __construct(
|
|
TaskRuleRepository $ruleRepo,
|
|
TaskAssignmentRepository $assignmentRepo,
|
|
TaskSubmissionRepository $submissionRepo,
|
|
CertificateService $certificateService,
|
|
TaskDefinitionRepository $definitionRepo,
|
|
?AssignmentResolverService $assignmentResolverService = null
|
|
) {
|
|
$this->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
|
|
}
|
|
}
|
|
}
|