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>
862 lines
33 KiB
PHP
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)) . '...';
|
|
}
|
|
}
|