Files
awo-hamburg-intranet/module/tasks/service/CompletionService.php
Moritz Weinmann 743476f64a 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>
2026-06-22 09:20:02 +02:00

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
}
}
}