Module tasks + knowledgecenter_update aus Kundenprojekt übernehmen (bereinigt)
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>
This commit is contained in:
451
module/tasks/service/CompletionService.php
Normal file
451
module/tasks/service/CompletionService.php
Normal file
@@ -0,0 +1,451 @@
|
||||
<?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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user