Files
awo-hamburg-intranet/module/tasks/service/TaskMonitoringService.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

862 lines
33 KiB
PHP

<?php
declare(strict_types=1);
class TaskMonitoringService
{
private TaskMonitoringRepository $monitoringRepo;
private CycleCalculatorService $cycleCalculator;
private TaskRuleRepository $ruleRepo;
public function __construct(
TaskMonitoringRepository $monitoringRepo,
CycleCalculatorService $cycleCalculator,
TaskRuleRepository $ruleRepo
) {
$this->monitoringRepo = $monitoringRepo;
$this->cycleCalculator = $cycleCalculator;
$this->ruleRepo = $ruleRepo;
}
public function buildDashboard(array $task, array $rawFilters): array
{
$taskId = (int) ($task['id'] ?? 0);
if ($taskId <= 0) {
return $this->buildEmptyDashboard();
}
$filters = $this->normalizeFilters($rawFilters);
$isCompletionBasedRecurring = (string) ($task['frequency_type'] ?? 'once') === 'recurring'
&& (string) ($task['recurrence_basis'] ?? 'calendar') === 'completion';
if ($isCompletionBasedRecurring) {
$repoFilters = [
'q' => $filters['q'],
'status' => $filters['status'],
'method_type' => $filters['method_type'],
'submission_status' => $filters['submission_status'],
];
$kpis = $this->monitoringRepo->getKpisForTaskLatest($taskId);
$todoRows = $this->monitoringRepo->listTodoAssignmentsLatest($taskId, $repoFilters, 100);
$completedRows = $this->monitoringRepo->listCompletedAssignmentsLatest($taskId, $repoFilters, 100);
$alerts = $this->monitoringRepo->listApprovalAndOverdueLatest($taskId, $repoFilters, 100);
$approvalRows = is_array($alerts['approvals'] ?? null) ? $alerts['approvals'] : [];
$overdueRows = is_array($alerts['overdue'] ?? null) ? $alerts['overdue'] : [];
$methodStats = $this->monitoringRepo->listMethodCompletionStatsLatest($taskId, $repoFilters, 100);
$methodMeta = $this->buildMethodMeta($taskId, $task);
$rawSubmissionRows = $this->monitoringRepo->listSubmissionAuditLatest($taskId, $repoFilters, 100);
$submissionData = $this->prepareSubmissionRows($rawSubmissionRows, $methodMeta);
$submissionRows = $submissionData['submission_rows'];
$exportRows = $submissionData['export_rows'];
foreach ($methodStats as &$methodStat) {
$methodStat['method_label'] = $this->methodLabel((string) ($methodStat['method_type'] ?? ''));
$methodStat['assignment_count'] = (int) ($methodStat['assignment_count'] ?? 0);
$methodStat['fulfilled_count'] = (int) ($methodStat['fulfilled_count'] ?? 0);
$methodStat['completion_rate'] = $methodStat['assignment_count'] > 0
? round(($methodStat['fulfilled_count'] / $methodStat['assignment_count']) * 100, 1)
: 0.0;
}
unset($methodStat);
$filters['cycle_key'] = '';
return [
'has_cycle' => true,
'no_cycle_reason' => '',
'show_cycle_selector' => false,
'cycle_options' => [],
'selected_cycle_key' => '',
'cycle_selection_warning' => '',
'active_tab_key' => (string) $filters['tab'],
'filters' => $filters,
'kpis' => $kpis,
'method_stats' => $methodStats,
'todo_rows' => $todoRows,
'completed_rows' => $completedRows,
'approval_rows' => $approvalRows,
'overdue_rows' => $overdueRows,
'submission_rows' => $submissionRows,
'export_rows' => $exportRows,
'export_mode' => 'snapshot',
'can_export_answers' => true,
'status_options' => ['' => 'Alle'] + TaskCertConstants::assignmentStatusLabels(),
'method_options' => ['' => 'Alle Methoden'] + TaskCertConstants::methodLabels(),
'submission_status_options' => ['' => 'Alle Einreichungsstatus'] + TaskCertConstants::submissionStatusLabels(),
];
}
$cycleSelection = $this->resolveCycleSelection(
$taskId,
$task,
trim((string) $filters['cycle_key'])
);
$cycleOptions = $cycleSelection['cycle_options'];
$selectedCycleKey = (string) $cycleSelection['selected_cycle_key'];
$hasCycle = (bool) $cycleSelection['has_cycle'];
$noCycleReason = (string) $cycleSelection['no_cycle_reason'];
$cycleSelectionWarning = (string) $cycleSelection['cycle_selection_warning'];
if ($selectedCycleKey !== '') {
$filters['cycle_key'] = $selectedCycleKey;
} else {
$filters['cycle_key'] = '';
}
$kpis = [
'total_assigned' => 0,
'completed_count' => 0,
'todo_count' => 0,
'overdue_count' => 0,
'awaiting_approval_count' => 0,
'escalated_count' => 0,
'with_submission_count' => 0,
'completion_rate' => 0.0,
'status_counts' => [
'open' => 0,
'in_progress' => 0,
'approval_pending' => 0,
'overdue' => 0,
'completed' => 0,
'expired' => 0,
'rejected' => 0,
],
];
$methodStats = [];
$todoRows = [];
$completedRows = [];
$approvalRows = [];
$overdueRows = [];
$submissionRows = [];
$exportRows = [];
if ($selectedCycleKey !== '') {
$repoFilters = [
'q' => $filters['q'],
'status' => $filters['status'],
'method_type' => $filters['method_type'],
'submission_status' => $filters['submission_status'],
];
$kpis = $this->monitoringRepo->getKpisForTaskCycle($taskId, $selectedCycleKey);
$todoRows = $this->monitoringRepo->listTodoAssignments($taskId, $selectedCycleKey, $repoFilters, 100);
$completedRows = $this->monitoringRepo->listCompletedAssignments($taskId, $selectedCycleKey, $repoFilters, 100);
$alerts = $this->monitoringRepo->listApprovalAndOverdue($taskId, $selectedCycleKey, $repoFilters, 100);
$approvalRows = is_array($alerts['approvals'] ?? null) ? $alerts['approvals'] : [];
$overdueRows = is_array($alerts['overdue'] ?? null) ? $alerts['overdue'] : [];
$methodStats = $this->monitoringRepo->listMethodCompletionStats($taskId, $selectedCycleKey, $repoFilters, 100);
$methodMeta = $this->buildMethodMeta($taskId, $task);
$rawSubmissionRows = $this->monitoringRepo->listSubmissionAudit($taskId, $selectedCycleKey, $repoFilters, 100);
$submissionData = $this->prepareSubmissionRows($rawSubmissionRows, $methodMeta);
$submissionRows = $submissionData['submission_rows'];
$exportRows = $submissionData['export_rows'];
foreach ($methodStats as &$methodStat) {
$methodStat['method_label'] = $this->methodLabel((string) ($methodStat['method_type'] ?? ''));
$methodStat['assignment_count'] = (int) ($methodStat['assignment_count'] ?? 0);
$methodStat['fulfilled_count'] = (int) ($methodStat['fulfilled_count'] ?? 0);
$methodStat['completion_rate'] = $methodStat['assignment_count'] > 0
? round(($methodStat['fulfilled_count'] / $methodStat['assignment_count']) * 100, 1)
: 0.0;
}
unset($methodStat);
}
return [
'has_cycle' => $hasCycle,
'no_cycle_reason' => $noCycleReason,
'show_cycle_selector' => true,
'cycle_options' => $cycleOptions,
'selected_cycle_key' => $selectedCycleKey,
'cycle_selection_warning' => $cycleSelectionWarning,
'active_tab_key' => (string) $filters['tab'],
'filters' => $filters,
'kpis' => $kpis,
'method_stats' => $methodStats,
'todo_rows' => $todoRows,
'completed_rows' => $completedRows,
'approval_rows' => $approvalRows,
'overdue_rows' => $overdueRows,
'submission_rows' => $submissionRows,
'export_rows' => $exportRows,
'export_mode' => 'cycle',
'can_export_answers' => $hasCycle,
'status_options' => [
'' => 'Alle',
'open' => 'Offen',
'in_progress' => 'In Bearbeitung',
'approval_pending' => 'Freigabe ausstehend',
'overdue' => 'Überfällig',
'completed' => 'Abgeschlossen',
'expired' => 'Abgelaufen',
'rejected' => 'Abgelehnt',
],
'method_options' => [
'' => 'Alle Methoden',
'knowledge_read' => $this->methodLabel('knowledge_read'),
'form_submit' => $this->methodLabel('form_submit'),
'quiz' => $this->methodLabel('quiz'),
'manual_approval' => $this->methodLabel('manual_approval'),
],
'submission_status_options' => [
'' => 'Alle Einreichungsstatus',
'submitted' => 'Eingereicht',
'auto_passed' => 'Automatisch bestanden',
'awaiting_approval' => 'Wartet auf Freigabe',
'approved' => 'Freigegeben',
'rejected' => 'Abgelehnt',
],
];
}
public function buildAnswersExport(array $task, array $rawFilters): array
{
$taskId = (int) ($task['id'] ?? 0);
if ($taskId <= 0) {
throw new InvalidArgumentException('Ungültige Aufgaben-ID.');
}
$isCompletionBasedRecurring = (string) ($task['frequency_type'] ?? 'once') === 'recurring'
&& (string) ($task['recurrence_basis'] ?? 'calendar') === 'completion';
$methodMeta = $this->buildMethodMeta($taskId, $task);
if ($isCompletionBasedRecurring) {
$rawRows = $this->monitoringRepo->listSubmissionAuditLatestForExport($taskId);
$submissionData = $this->prepareSubmissionRows($rawRows, $methodMeta);
return [
'mode' => 'snapshot',
'cycle_key' => '',
'rows' => $submissionData['export_rows'],
];
}
$requestedCycleKey = trim((string) ($rawFilters['cycle_key'] ?? ''));
$cycleSelection = $this->resolveCycleSelection($taskId, $task, $requestedCycleKey);
if ($requestedCycleKey !== '' && !(bool) $cycleSelection['requested_cycle_is_valid']) {
throw new InvalidArgumentException('Ungültiger Zyklus.');
}
$selectedCycleKey = trim((string) ($cycleSelection['selected_cycle_key'] ?? ''));
if ($selectedCycleKey === '') {
throw new InvalidArgumentException('Kein Zyklus verfügbar.');
}
$rawRows = $this->monitoringRepo->listSubmissionAuditForCycleExport($taskId, $selectedCycleKey);
$submissionData = $this->prepareSubmissionRows($rawRows, $methodMeta);
return [
'mode' => 'cycle',
'cycle_key' => $selectedCycleKey,
'rows' => $submissionData['export_rows'],
];
}
private function buildEmptyDashboard(): array
{
return [
'has_cycle' => false,
'no_cycle_reason' => 'Aktuell ist kein Zyklus verfügbar.',
'show_cycle_selector' => false,
'cycle_options' => [],
'selected_cycle_key' => '',
'cycle_selection_warning' => '',
'active_tab_key' => 'overview',
'filters' => [
'cycle_key' => '',
'q' => '',
'status' => '',
'method_type' => '',
'submission_status' => '',
'tab' => 'overview',
],
'kpis' => [],
'method_stats' => [],
'todo_rows' => [],
'completed_rows' => [],
'approval_rows' => [],
'overdue_rows' => [],
'submission_rows' => [],
'export_rows' => [],
'export_mode' => 'cycle',
'can_export_answers' => false,
'status_options' => [],
'method_options' => [],
'submission_status_options' => [],
];
}
private function normalizeFilters(array $rawFilters): array
{
$allowedTabs = ['overview', 'todo', 'completed', 'approvals', 'overdue', 'answers'];
$status = trim((string) ($rawFilters['status'] ?? ''));
if (!TaskCertConstants::isValidAssignmentStatus($status)) {
$status = '';
}
$methodType = trim((string) ($rawFilters['method_type'] ?? ''));
if (!TaskCertConstants::isValidMethodType($methodType)) {
$methodType = '';
}
$submissionStatus = trim((string) ($rawFilters['submission_status'] ?? ''));
if (!TaskCertConstants::isValidSubmissionStatus($submissionStatus)) {
$submissionStatus = '';
}
$tab = trim((string) ($rawFilters['tab'] ?? 'overview'));
if ($tab === 'alerts') {
$tab = 'approvals';
}
if (!in_array($tab, $allowedTabs, true)) {
$tab = 'overview';
}
$query = trim((string) ($rawFilters['q'] ?? ''));
if (function_exists('mb_substr')) {
$query = mb_substr($query, 0, 120);
} else {
$query = substr($query, 0, 120);
}
return [
'cycle_key' => trim((string) ($rawFilters['cycle_key'] ?? '')),
'q' => $query,
'status' => $status,
'method_type' => $methodType,
'submission_status' => $submissionStatus,
'tab' => $tab,
];
}
private function resolveCycleSelection(int $taskId, array $task, string $requestedCycleKey): array
{
$requestedCycleKey = trim($requestedCycleKey);
$requestedCycleIsValid = true;
$cycleRows = $this->monitoringRepo->listCycleKeysForTask($taskId, 24);
$cycleMap = [];
foreach ($cycleRows as $row) {
$cycleKey = trim((string) ($row['cycle_key'] ?? ''));
if ($cycleKey === '') {
continue;
}
$cycleMap[$cycleKey] = $row;
}
if ($requestedCycleKey !== '' && !isset($cycleMap[$requestedCycleKey])) {
$requestedCycleRow = $this->monitoringRepo->findCycleKeyForTask($taskId, $requestedCycleKey);
if (is_array($requestedCycleRow)) {
$cycleMap[$requestedCycleKey] = $requestedCycleRow;
} else {
$requestedCycleIsValid = false;
}
}
$currentCycle = $this->cycleCalculator->resolveCurrentCycle($task, new DateTimeImmutable());
$currentCycleKey = trim((string) ($currentCycle['cycle_key'] ?? ''));
if ($currentCycleKey !== '' && !isset($cycleMap[$currentCycleKey])) {
$cycleMap[$currentCycleKey] = [
'cycle_key' => $currentCycleKey,
'cycle_start_at' => $this->toDateTimeString($currentCycle['cycle_start_at'] ?? null),
'cycle_end_at' => $this->toDateTimeString($currentCycle['cycle_end_at'] ?? null),
'assignment_count' => 0,
];
}
$cycleOptions = [];
foreach ($cycleMap as $cycleKey => $cycleRow) {
$cycleOptions[] = [
'cycle_key' => $cycleKey,
'label' => $this->formatCycleLabel($cycleKey, $cycleRow),
'cycle_start_at' => (string) ($cycleRow['cycle_start_at'] ?? ''),
'cycle_end_at' => (string) ($cycleRow['cycle_end_at'] ?? ''),
'assignment_count' => (int) ($cycleRow['assignment_count'] ?? 0),
];
}
usort($cycleOptions, static function (array $a, array $b): int {
$aStart = (string) ($a['cycle_start_at'] ?? '');
$bStart = (string) ($b['cycle_start_at'] ?? '');
if ($aStart !== '' && $bStart !== '' && $aStart !== $bStart) {
return strcmp($bStart, $aStart);
}
return strcmp((string) $b['cycle_key'], (string) $a['cycle_key']);
});
$selectedCycleKey = '';
if ($requestedCycleKey !== '' && isset($cycleMap[$requestedCycleKey])) {
$selectedCycleKey = $requestedCycleKey;
} elseif ($currentCycleKey !== '') {
$selectedCycleKey = $currentCycleKey;
} elseif ($cycleOptions !== []) {
$selectedCycleKey = (string) ($cycleOptions[0]['cycle_key'] ?? '');
}
$hasCycle = $selectedCycleKey !== '';
$noCycleReason = '';
if (!$hasCycle) {
$startDateRaw = trim((string) ($task['start_date'] ?? ''));
if ($startDateRaw !== '') {
try {
$startDate = new DateTimeImmutable($startDateRaw);
$today = new DateTimeImmutable('today');
if ($startDate > $today) {
$noCycleReason = 'Der erste Zyklus startet am ' . $startDate->format('d.m.Y') . '.';
}
} catch (Throwable $throwable) {
$noCycleReason = '';
}
}
if ($noCycleReason === '') {
$noCycleReason = 'Aktuell ist kein Zyklus verfügbar. Bitte Startdatum, Rhythmus und Zuweisungen prüfen.';
}
}
$cycleSelectionWarning = '';
if ($requestedCycleKey !== '' && !$requestedCycleIsValid) {
if ($selectedCycleKey !== '') {
$cycleSelectionWarning = 'Der angeforderte Zyklus "' . $requestedCycleKey
. '" ist ungültig oder nicht vorhanden. Es wird "' . $selectedCycleKey . '" angezeigt.';
} else {
$cycleSelectionWarning = 'Der angeforderte Zyklus "' . $requestedCycleKey
. '" ist ungültig oder nicht vorhanden.';
}
}
return [
'cycle_options' => $cycleOptions,
'selected_cycle_key' => $selectedCycleKey,
'has_cycle' => $hasCycle,
'no_cycle_reason' => $noCycleReason,
'cycle_selection_warning' => $cycleSelectionWarning,
'requested_cycle_is_valid' => $requestedCycleIsValid,
];
}
private function prepareSubmissionRows(array $rawSubmissionRows, array $methodMeta): array
{
$submissionRows = [];
$exportRows = [];
foreach ($rawSubmissionRows as $row) {
$payload = $this->decodePayload((string) ($row['payload_json'] ?? ''));
$normalized = $this->normalizeSubmission($row, $payload, $methodMeta[(int) ($row['method_id'] ?? 0)] ?? []);
$answerDetailJson = $this->encodeJson((array) ($normalized['detail'] ?? []), false);
$answerDetailPrettyJson = $this->encodeJson((array) ($normalized['detail'] ?? []), true);
$methodLabel = $this->methodLabel((string) ($row['method_type'] ?? ''));
$row['answer_summary'] = (string) ($normalized['summary'] ?? '');
$row['answer_items'] = is_array($normalized['items'] ?? null) ? $normalized['items'] : [];
$row['answer_detail'] = is_array($normalized['detail'] ?? null) ? $normalized['detail'] : [];
$row['answer_detail_json'] = $answerDetailJson;
$row['answer_detail_pretty_json'] = $answerDetailPrettyJson;
$row['method_label'] = $methodLabel;
$submissionRows[] = $row;
$exportRows[] = [
'submission_id' => (int) ($row['id'] ?? 0),
'assignment_id' => (int) ($row['assignment_id'] ?? 0),
'cycle_key' => (string) ($row['cycle_key'] ?? ''),
'contact_id' => (int) ($row['main_contact_id'] ?? 0),
'contact_name' => trim((string) ($row['contact_name'] ?? '')),
'contact_email' => trim((string) ($row['contact_email'] ?? '')),
'method_id' => (int) ($row['method_id'] ?? 0),
'method_type' => (string) ($row['method_type'] ?? ''),
'method_label' => $methodLabel,
'submission_status' => (string) ($row['status'] ?? ''),
'assignment_status' => (string) ($row['assignment_status'] ?? ''),
'submitted_at' => (string) ($row['submitted_at'] ?? ''),
'due_at' => (string) ($row['due_at'] ?? ''),
'overdue_at' => (string) ($row['overdue_at'] ?? ''),
'completed_at' => (string) ($row['completed_at'] ?? ''),
'escalation_level' => (int) ($row['escalation_level'] ?? 0),
'score' => $row['score'] !== null ? (int) $row['score'] : null,
'max_score' => $row['max_score'] !== null ? (int) $row['max_score'] : null,
'review_note' => trim((string) ($row['review_note'] ?? '')),
'answer_summary' => (string) ($normalized['summary'] ?? ''),
'answer_detail_json' => $answerDetailJson,
];
}
return [
'submission_rows' => $submissionRows,
'export_rows' => $exportRows,
];
}
private function buildMethodMeta(int $taskId, array $task): array
{
$methods = [];
if (isset($task['rules']['methods']) && is_array($task['rules']['methods'])) {
$methods = $task['rules']['methods'];
}
if ($methods === []) {
$methods = $this->ruleRepo->getMethodsByTask($taskId);
}
$meta = [];
foreach ($methods as $method) {
$methodId = (int) ($method['id'] ?? 0);
if ($methodId <= 0) {
continue;
}
$entry = [
'method_type' => (string) ($method['method_type'] ?? ''),
'form_field_labels' => [],
'quiz_question_labels' => [],
'quiz_option_labels' => [],
];
foreach ((array) ($method['form_fields'] ?? []) as $field) {
$fieldKey = trim((string) ($field['field_key'] ?? ''));
if ($fieldKey === '') {
continue;
}
$entry['form_field_labels'][$fieldKey] = trim((string) ($field['label'] ?? $fieldKey));
}
foreach ((array) ($method['quiz_questions'] ?? []) as $question) {
$questionId = (int) ($question['id'] ?? 0);
if ($questionId > 0) {
$entry['quiz_question_labels'][$questionId] = trim((string) ($question['question_text'] ?? ('Frage #' . $questionId)));
}
foreach ((array) ($question['options'] ?? []) as $option) {
$optionId = (int) ($option['id'] ?? 0);
if ($optionId <= 0) {
continue;
}
$entry['quiz_option_labels'][$optionId] = trim((string) ($option['option_text'] ?? ('Option #' . $optionId)));
}
}
$meta[$methodId] = $entry;
}
return $meta;
}
private function normalizeSubmission(array $row, array $payload, array $methodMeta): array
{
$methodType = (string) ($row['method_type'] ?? '');
$summary = 'Einreichung vorhanden';
$items = [];
$detail = $payload;
if ($methodType === 'knowledge_read') {
$postIds = [];
foreach ((array) ($payload['post_ids'] ?? []) as $postId) {
$postIdInt = (int) $postId;
if ($postIdInt > 0) {
$postIds[] = $postIdInt;
}
}
$postIds = array_values(array_unique($postIds));
$summary = count($postIds) . ' Beiträge bestätigt';
$items[] = [
'label' => 'Beitrags-IDs',
'value' => $postIds === [] ? '-' : implode(', ', $postIds),
];
$detail = [
'post_ids' => $postIds,
];
} elseif ($methodType === 'form_submit') {
$values = is_array($payload['form_values'] ?? null) ? $payload['form_values'] : [];
$labels = is_array($methodMeta['form_field_labels'] ?? null)
? $methodMeta['form_field_labels']
: [];
$summaryParts = [];
$detailRows = [];
$submissionId = (int) ($row['id'] ?? 0);
foreach ($values as $fieldKey => $value) {
$fieldKey = (string) $fieldKey;
$label = trim((string) ($labels[$fieldKey] ?? $fieldKey));
$displayValue = $this->normalizeDisplayValue($value);
$item = [
'label' => $label,
'value' => $displayValue,
];
// Datei-Upload-Felder: pro Datei Referenzdaten mitgeben, damit die
// Seiten daraus Download-Links bauen können.
if ($this->isFileValue($value)) {
$files = [];
foreach (array_values($value) as $i => $fileMeta) {
if (!is_array($fileMeta)) {
continue;
}
$fileName = trim((string) ($fileMeta['file_name'] ?? ''));
$files[] = [
'name' => $fileName !== '' ? $fileName : 'Datei',
'submission_id' => $submissionId,
'field_key' => $fieldKey,
'index' => $i,
];
}
if ($files !== []) {
$item['files'] = $files;
}
}
$items[] = $item;
$detailRows[] = [
'field_key' => $fieldKey,
'label' => $label,
'value' => $value,
];
if (count($summaryParts) < 2) {
$summaryParts[] = $label . ': ' . $displayValue;
}
}
if ($summaryParts === []) {
$summary = 'Keine Feldwerte';
} else {
$summary = implode(' | ', $summaryParts);
if (count($items) > 2) {
$summary .= ' | +' . (count($items) - 2) . ' weitere';
}
}
$detail = [
'form_values' => $detailRows,
];
} elseif ($methodType === 'quiz') {
$score = $row['score'] !== null ? (int) $row['score'] : (int) ($payload['score'] ?? 0);
$maxScore = $row['max_score'] !== null ? (int) $row['max_score'] : (int) ($payload['max_score'] ?? 0);
if ($maxScore > 0) {
$summary = $score . '/' . $maxScore . ' korrekt';
} else {
$summary = 'Quiz eingereicht';
}
$questionLabels = is_array($methodMeta['quiz_question_labels'] ?? null)
? $methodMeta['quiz_question_labels']
: [];
$optionLabels = is_array($methodMeta['quiz_option_labels'] ?? null)
? $methodMeta['quiz_option_labels']
: [];
$answers = is_array($payload['answers'] ?? null) ? $payload['answers'] : [];
$normalizedAnswers = [];
foreach ($answers as $questionId => $optionIds) {
$questionIdInt = (int) $questionId;
if (!is_array($optionIds)) {
$optionIds = [$optionIds];
}
$resolvedOptions = [];
foreach ($optionIds as $optionId) {
$optionIdInt = (int) $optionId;
if ($optionIdInt <= 0) {
continue;
}
$resolvedOptions[] = $optionLabels[$optionIdInt] ?? ('Option #' . $optionIdInt);
}
$resolvedOptions = array_values(array_unique($resolvedOptions));
$questionLabel = $questionLabels[$questionIdInt] ?? ('Frage #' . $questionIdInt);
$display = $resolvedOptions === [] ? '-' : implode(', ', $resolvedOptions);
$items[] = [
'label' => $questionLabel,
'value' => $display,
];
$normalizedAnswers[] = [
'question_id' => $questionIdInt,
'question' => $questionLabel,
'answers' => $resolvedOptions,
];
}
$detail = [
'score' => $score,
'max_score' => $maxScore,
'answers' => $normalizedAnswers,
];
} elseif ($methodType === 'manual_approval') {
$note = trim((string) ($payload['note'] ?? ''));
$reviewNote = trim((string) ($row['review_note'] ?? ''));
$summary = $note !== ''
? $this->truncate($note, 90)
: 'Antrag eingereicht';
$items[] = [
'label' => 'Antrag',
'value' => $note !== '' ? $note : '-',
];
if ($reviewNote !== '') {
$items[] = [
'label' => 'Review-Notiz',
'value' => $reviewNote,
];
}
$detail = [
'note' => $note,
'review_note' => $reviewNote,
];
}
return [
'summary' => $summary,
'items' => $items,
'detail' => $detail,
];
}
private function encodeJson(array $payload, bool $pretty): string
{
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
if ($pretty) {
$flags |= JSON_PRETTY_PRINT;
}
$encoded = json_encode($payload, $flags);
if ($encoded === false) {
return '{}';
}
return $encoded;
}
private function isFileValue($value): bool
{
if (!is_array($value) || $value === []) {
return false;
}
$first = reset($value);
return is_array($first) && array_key_exists('file_path', $first);
}
private function normalizeDisplayValue($value): string
{
if (is_array($value)) {
$items = [];
foreach ($value as $item) {
// Datei-Metadaten als Dateiname darstellen statt "Array".
$itemString = is_array($item)
? trim((string) ($item['file_name'] ?? ''))
: trim((string) $item);
if ($itemString !== '') {
$items[] = $itemString;
}
}
return $items === [] ? '-' : implode(', ', array_values(array_unique($items)));
}
$stringValue = trim((string) $value);
return $stringValue !== '' ? $stringValue : '-';
}
private function decodePayload(string $payloadJson): array
{
if (trim($payloadJson) === '') {
return [];
}
$decoded = json_decode($payloadJson, true);
return is_array($decoded) ? $decoded : [];
}
private function formatCycleLabel(string $cycleKey, array $cycleRow): string
{
$start = $this->formatDateShort((string) ($cycleRow['cycle_start_at'] ?? ''));
$end = $this->formatDateShort((string) ($cycleRow['cycle_end_at'] ?? ''));
if ($start !== '' && $end !== '') {
return $cycleKey . ' (' . $start . ' - ' . $end . ')';
}
return $cycleKey;
}
private function formatDateShort(string $value): string
{
if (trim($value) === '') {
return '';
}
try {
return (new DateTimeImmutable($value))->format('d.m.Y');
} catch (Throwable $throwable) {
return $value;
}
}
private function toDateTimeString($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 methodLabel(string $methodType): string
{
$map = TaskCertConstants::methodLabels();
return $map[$methodType] ?? $methodType;
}
private function truncate(string $value, int $maxLength): string
{
if ($maxLength < 4) {
return $value;
}
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($value) <= $maxLength) {
return $value;
}
return rtrim(mb_substr($value, 0, $maxLength - 3)) . '...';
}
if (strlen($value) <= $maxLength) {
return $value;
}
return rtrim(substr($value, 0, $maxLength - 3)) . '...';
}
}