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

1226 lines
48 KiB
PHP

<?php
declare(strict_types=1);
class TaskDefinitionService
{
private TaskDefinitionRepository $definitionRepo;
private TaskCategoryRepository $categoryRepo;
private TaskRuleRepository $ruleRepo;
private TaskAssignmentRepository $assignmentRepo;
private TaskSubmissionRepository $submissionRepo;
private TaskCertificateRepository $certificateRepo;
private CycleCalculatorService $cycleCalculator;
private AssignmentResolverService $assignmentResolver;
private TaskChangeLogRepository $changeLogRepo;
public function __construct(
TaskDefinitionRepository $definitionRepo,
TaskCategoryRepository $categoryRepo,
TaskRuleRepository $ruleRepo,
TaskAssignmentRepository $assignmentRepo,
TaskSubmissionRepository $submissionRepo,
TaskCertificateRepository $certificateRepo,
CycleCalculatorService $cycleCalculator,
AssignmentResolverService $assignmentResolver,
TaskChangeLogRepository $changeLogRepo
) {
$this->definitionRepo = $definitionRepo;
$this->categoryRepo = $categoryRepo;
$this->ruleRepo = $ruleRepo;
$this->assignmentRepo = $assignmentRepo;
$this->submissionRepo = $submissionRepo;
$this->certificateRepo = $certificateRepo;
$this->cycleCalculator = $cycleCalculator;
$this->assignmentResolver = $assignmentResolver;
$this->changeLogRepo = $changeLogRepo;
}
public function createTask(array $dto): int
{
$actorId = (int) ($dto['actor_id'] ?? 0);
if ($actorId <= 0) {
throw new InvalidArgumentException('Missing actor_id for createTask');
}
$normalized = $this->normalizeDefinitionData($dto, $actorId, true);
TaskCertDb::beginTransaction();
try {
$taskId = $this->definitionRepo->create($normalized);
$this->ruleRepo->replaceTaskRules($taskId, $dto, $actorId);
$afterSnapshot = $this->buildTaskConfigSnapshot($taskId);
$this->changeLogRepo->create(
$taskId,
'create',
$actorId,
null,
$afterSnapshot,
$this->buildSnapshotDiff([], $afterSnapshot)
);
TaskCertDb::commit();
} catch (Throwable $throwable) {
TaskCertDb::rollback();
throw $throwable;
}
$this->assignmentResolver->materializeAssignmentsForTask($taskId);
return $taskId;
}
public function updateTask(int $taskId, array $dto): void
{
$existing = $this->definitionRepo->findById($taskId);
if ($existing === null) {
throw new RuntimeException('Aufgabe nicht gefunden');
}
$actorId = (int) ($dto['actor_id'] ?? 0);
if ($actorId <= 0) {
throw new InvalidArgumentException('Missing actor_id for updateTask');
}
$normalized = $this->normalizeDefinitionData($dto, $actorId, false, $existing);
$beforeSnapshot = $this->buildTaskConfigSnapshot($taskId);
TaskCertDb::beginTransaction();
try {
$this->definitionRepo->update($taskId, $normalized);
$this->ruleRepo->replaceTaskRules($taskId, $dto, $actorId);
$afterSnapshot = $this->buildTaskConfigSnapshot($taskId);
$this->changeLogRepo->create(
$taskId,
'update',
$actorId,
$beforeSnapshot,
$afterSnapshot,
$this->buildSnapshotDiff($beforeSnapshot, $afterSnapshot)
);
TaskCertDb::commit();
} catch (Throwable $throwable) {
TaskCertDb::rollback();
throw $throwable;
}
$this->assignmentResolver->materializeAssignmentsForTask($taskId);
}
public function resetCurrentCycleProgress(int $taskId, int $actorId): array
{
if ($actorId <= 0) {
throw new InvalidArgumentException('Missing actor_id for resetCurrentCycleProgress');
}
$task = $this->definitionRepo->findById($taskId);
if ($task === null) {
throw new RuntimeException('Aufgabe nicht gefunden');
}
$cycle = $this->cycleCalculator->resolveCurrentCycle($task, new DateTimeImmutable());
$cycleKey = trim((string) ($cycle['cycle_key'] ?? ''));
if ($cycleKey === '') {
return [
'cycle_key' => '',
'assignment_count' => 0,
'submission_count' => 0,
'certificate_count' => 0,
];
}
$frequencyType = (string) ($task['frequency_type'] ?? 'once');
$assignments = $frequencyType === 'once'
? $this->assignmentRepo->listByTask($taskId)
: $this->assignmentRepo->listByTaskAndCycleKey($taskId, $cycleKey);
$assignmentIds = [];
foreach ($assignments as $assignment) {
$assignmentId = (int) ($assignment['id'] ?? 0);
if ($assignmentId > 0) {
$assignmentIds[] = $assignmentId;
}
}
$assignmentIds = array_values(array_unique($assignmentIds));
if ($assignmentIds === []) {
return [
'cycle_key' => $cycleKey,
'assignment_count' => 0,
'submission_count' => 0,
'certificate_count' => 0,
];
}
// Datei-Pfade vor dem Löschen einsammeln, damit hochgeladene Dateien
// nicht als Waisen auf der Platte zurückbleiben.
$filePaths = $this->submissionRepo->collectFilePathsByAssignmentIds($assignmentIds);
TaskCertDb::beginTransaction();
try {
$certificateCount = $this->certificateRepo->deleteByAssignmentIds($assignmentIds);
$submissionCount = $this->submissionRepo->deleteByAssignmentIds($assignmentIds);
$this->assignmentRepo->resetByIds($assignmentIds);
$this->changeLogRepo->create(
$taskId,
'reset_cycle_progress',
$actorId,
null,
null,
[
'cycle_key' => $cycleKey,
'frequency_type' => $frequencyType,
'assignment_count' => count($assignmentIds),
'submission_count' => (int) $submissionCount,
'certificate_count' => (int) $certificateCount,
]
);
TaskCertDb::commit();
} catch (Throwable $throwable) {
TaskCertDb::rollback();
throw $throwable;
}
// Erst nach erfolgreichem Commit die Dateien entfernen.
TaskUploadStorage::deleteFiles($filePaths);
return [
'cycle_key' => $cycleKey,
'assignment_count' => count($assignmentIds),
'submission_count' => (int) $submissionCount,
'certificate_count' => (int) $certificateCount,
];
}
public function resetAssignmentProgress(int $taskId, int $assignmentId, int $actorId): array
{
if ($actorId <= 0) {
throw new InvalidArgumentException('Missing actor_id for resetAssignmentProgress');
}
if ($taskId <= 0) {
throw new InvalidArgumentException('Ungültige Aufgaben-ID.');
}
if ($assignmentId <= 0) {
throw new InvalidArgumentException('Ungültige Zuweisungs-ID.');
}
$task = $this->definitionRepo->findById($taskId);
if ($task === null) {
throw new RuntimeException('Aufgabe nicht gefunden');
}
$assignment = $this->assignmentRepo->findById($assignmentId);
if ($assignment === null) {
throw new RuntimeException('Zuweisung nicht gefunden.');
}
if ((int) ($assignment['task_id'] ?? 0) !== $taskId) {
throw new RuntimeException('Die Zuweisung gehört nicht zur ausgewählten Aufgabe.');
}
$assignmentStatus = (string) ($assignment['status'] ?? '');
$nonResettableStatuses = ['expired'];
if (in_array($assignmentStatus, $nonResettableStatuses, true)) {
throw new RuntimeException('Abgelaufene Zuweisungen können nicht zurückgesetzt werden.');
}
if ($assignmentStatus === 'open') {
throw new RuntimeException('Diese Zuweisung ist bereits offen und muss nicht zurückgesetzt werden.');
}
$frequencyType = (string) ($task['frequency_type'] ?? 'once');
$recurrenceBasis = (string) ($task['recurrence_basis'] ?? 'calendar');
if ($frequencyType === 'recurring') {
$contactId = (int) ($assignment['main_contact_id'] ?? 0);
if ($contactId <= 0) {
throw new RuntimeException('Ungültiger Mitarbeiter in der Zuweisung.');
}
if ($recurrenceBasis === 'completion') {
$latestAssignment = $this->assignmentRepo->findLatestByTaskAndContact($taskId, $contactId);
if ((int) ($latestAssignment['id'] ?? 0) !== $assignmentId) {
throw new RuntimeException('Bei erledigungsbasierten Aufgaben kann nur der aktuellste Zeitraum je Mitarbeiter zurückgesetzt werden.');
}
} else {
// Vergangene Zyklen dürfen zurückgesetzt werden (Admin-Korrektur).
}
}
$assignmentIds = [$assignmentId];
$cycleKey = (string) ($assignment['cycle_key'] ?? '');
// Datei-Pfade vor dem Löschen einsammeln (Aufräumen nach Commit).
$filePaths = $this->submissionRepo->collectFilePathsByAssignmentIds($assignmentIds);
TaskCertDb::beginTransaction();
try {
$certificateCount = $this->certificateRepo->deleteByAssignmentIds($assignmentIds);
$submissionCount = $this->submissionRepo->deleteByAssignmentIds($assignmentIds);
$this->assignmentRepo->resetByIds($assignmentIds);
$this->changeLogRepo->create(
$taskId,
'reset_assignment_progress',
$actorId,
null,
null,
[
'assignment_id' => $assignmentId,
'main_contact_id' => (int) ($assignment['main_contact_id'] ?? 0),
'cycle_key' => $cycleKey,
'frequency_type' => (string) ($task['frequency_type'] ?? 'once'),
'recurrence_basis' => $recurrenceBasis,
'assignment_count' => 1,
'submission_count' => (int) $submissionCount,
'certificate_count' => (int) $certificateCount,
]
);
TaskCertDb::commit();
} catch (Throwable $throwable) {
TaskCertDb::rollback();
throw $throwable;
}
// Erst nach erfolgreichem Commit die Dateien entfernen.
TaskUploadStorage::deleteFiles($filePaths);
return [
'task_id' => $taskId,
'assignment_id' => $assignmentId,
'cycle_key' => $cycleKey,
'assignment_count' => 1,
'submission_count' => (int) $submissionCount,
'certificate_count' => (int) $certificateCount,
];
}
public function adminMarkCompleted(
int $taskId,
int $assignmentId,
int $actorId,
array $methodPayloads,
string $adminNote
): array {
if ($actorId <= 0) {
throw new InvalidArgumentException('Missing actor_id for adminMarkCompleted');
}
$task = $this->definitionRepo->findById($taskId);
if ($task === null) {
throw new RuntimeException('Aufgabe nicht gefunden.');
}
$assignment = $this->assignmentRepo->findById($assignmentId);
if ($assignment === null) {
throw new RuntimeException('Zuweisung nicht gefunden.');
}
if ((int) ($assignment['task_id'] ?? 0) !== $taskId) {
throw new RuntimeException('Die Zuweisung gehört nicht zur ausgewählten Aufgabe.');
}
$status = (string) ($assignment['status'] ?? '');
if ($status === 'completed') {
throw new RuntimeException('Diese Zuweisung ist bereits abgeschlossen.');
}
if ($status === 'expired') {
throw new RuntimeException('Abgelaufene Zuweisungen können nicht als erledigt markiert werden.');
}
$contactId = (int) ($assignment['main_contact_id'] ?? 0);
$cycleKey = (string) ($assignment['cycle_key'] ?? '');
$methods = array_values(array_filter(
$this->ruleRepo->getMethodsByTask($taskId),
static fn(array $m): bool => (int) ($m['active'] ?? 1) === 1
));
TaskCertDb::beginTransaction();
try {
$lastSubmissionId = 0;
$submissionCount = 0;
foreach ($methods as $method) {
$methodId = (int) ($method['id'] ?? 0);
$methodType = (string) ($method['method_type'] ?? '');
if ($methodId <= 0) {
continue;
}
$existing = $this->submissionRepo->findLatestByAssignmentAndMethodAndStatuses(
$assignmentId,
$methodId,
['auto_passed', 'approved']
);
if ($existing !== null) {
$lastSubmissionId = (int) $existing['id'];
continue;
}
$payload = $methodPayloads[$methodId] ?? [];
$submissionStatus = 'auto_passed';
$score = null;
$maxScore = null;
if ($methodType === 'knowledge_read') {
$postIds = [];
foreach ((array) ($method['knowledge_posts'] ?? []) as $post) {
$postIds[] = (int) ($post['knowledge_post_id'] ?? 0);
}
$postIds = array_values(array_filter($postIds, static fn(int $id): bool => $id > 0));
$payload = [
'post_ids' => $postIds,
'missing_post_ids' => [],
'is_complete' => true,
'_admin_completed' => true,
];
} elseif ($methodType === 'form_submit') {
$formValues = [];
foreach ((array) ($method['form_fields'] ?? []) as $field) {
$key = (string) ($field['field_key'] ?? '');
if ($key === '') {
continue;
}
$formValues[$key] = $payload[$key] ?? '';
}
$payload = [
'form_values' => $formValues,
'_admin_completed' => true,
];
} elseif ($methodType === 'quiz') {
$questionCount = count((array) ($method['quiz_questions'] ?? []));
$score = $questionCount;
$maxScore = $questionCount;
$payload = [
'answers' => [],
'score' => $questionCount,
'max_score' => $questionCount,
'_admin_completed' => true,
];
} elseif ($methodType === 'manual_approval') {
$submissionStatus = 'approved';
$payload = [
'note' => trim($adminNote) !== '' ? trim($adminNote) : 'Admin-Freigabe',
'_admin_completed' => true,
];
}
$submissionId = $this->submissionRepo->create(
$assignmentId,
$methodId,
$contactId,
$submissionStatus,
$payload,
$score,
$maxScore
);
if ($methodType === 'manual_approval') {
$this->submissionRepo->review($submissionId, 'approved', $actorId, trim($adminNote));
}
$lastSubmissionId = $submissionId;
$submissionCount++;
}
$now = (new DateTimeImmutable())->format('Y-m-d H:i:s');
$this->assignmentRepo->updateStatus($assignmentId, 'completed', $now);
$this->assignmentRepo->clearEscalation($assignmentId);
$certificateService = new CertificateService(
$this->certificateRepo,
$this->assignmentRepo,
$this->definitionRepo,
$this->cycleCalculator
);
$certificateId = 0;
if ($lastSubmissionId > 0) {
$certificateId = $certificateService->issueForAssignment($assignmentId, $lastSubmissionId);
}
$this->changeLogRepo->create(
$taskId,
'admin_mark_completed',
$actorId,
null,
null,
[
'assignment_id' => $assignmentId,
'main_contact_id' => $contactId,
'cycle_key' => $cycleKey,
'submission_count' => $submissionCount,
'certificate_id' => $certificateId,
'admin_note' => trim($adminNote),
]
);
TaskCertDb::commit();
} catch (Throwable $throwable) {
TaskCertDb::rollback();
throw $throwable;
}
return [
'task_id' => $taskId,
'assignment_id' => $assignmentId,
'cycle_key' => $cycleKey,
'submission_count' => $submissionCount,
'certificate_id' => $certificateId,
];
}
public function softDeleteTask(int $taskId): void
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
throw new RuntimeException('Nicht eingeloggt');
}
$existing = $this->definitionRepo->findById($taskId);
if ($existing === null) {
throw new RuntimeException('Aufgabe nicht gefunden');
}
$actorId = $auth->contactId();
$beforeSnapshot = $this->buildTaskConfigSnapshot($taskId);
TaskCertDb::beginTransaction();
try {
$this->definitionRepo->softDelete($taskId, $actorId);
$afterSnapshot = $this->buildTaskConfigSnapshot($taskId);
$this->changeLogRepo->create(
$taskId,
'soft_delete',
$actorId,
$beforeSnapshot,
$afterSnapshot,
$this->buildSnapshotDiff($beforeSnapshot, $afterSnapshot)
);
TaskCertDb::commit();
} catch (Throwable $throwable) {
TaskCertDb::rollback();
throw $throwable;
}
}
public function hardDeleteTask(int $taskId): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
throw new RuntimeException('Nicht eingeloggt');
}
$existing = $this->definitionRepo->findById($taskId);
if ($existing === null) {
throw new RuntimeException('Aufgabe nicht gefunden');
}
$actorId = $auth->contactId();
$beforeSnapshot = $this->buildTaskConfigSnapshot($taskId);
$impact = $this->definitionRepo->getHardDeleteImpact($taskId);
// Datei-Pfade vor dem CASCADE-Löschen einsammeln (Aufräumen nach Commit).
$filePaths = $this->submissionRepo->collectFilePathsByTaskId($taskId);
TaskCertDb::beginTransaction();
try {
$deletedRows = $this->definitionRepo->hardDelete($taskId);
if ($deletedRows <= 0) {
throw new RuntimeException('Aufgabe konnte nicht gelöscht werden.');
}
$this->changeLogRepo->create(
$taskId,
'hard_delete',
$actorId,
$beforeSnapshot,
null,
[
'deleted' => true,
'impact' => $impact,
]
);
TaskCertDb::commit();
} catch (Throwable $throwable) {
TaskCertDb::rollback();
throw $throwable;
}
// Erst nach erfolgreichem Commit die Dateien entfernen.
TaskUploadStorage::deleteFiles($filePaths);
return $impact;
}
public function deleteTask(int $taskId): void
{
$this->softDeleteTask($taskId);
}
public function listTasksForAdmin(string $searchQuery = '', int $categoryIdFilter = 0): array
{
return $this->definitionRepo->listForAdmin($searchQuery, $categoryIdFilter);
}
public function listTasksForResponsible(int $contactId): array
{
return $this->definitionRepo->listForResponsible($contactId);
}
public function getTaskDetail(int $taskId): ?array
{
$task = $this->definitionRepo->findById($taskId);
if ($task === null) {
return null;
}
$task['rules'] = $this->ruleRepo->getTaskRuleBundle($taskId);
$task['assignment_summary'] = $this->assignmentRepo->listTaskAssignmentSummary($taskId);
return $task;
}
private function normalizeDefinitionData(
array $dto,
int $actorId,
bool $isCreate,
array $existing = []
): array {
$title = trim((string) ($dto['title'] ?? ($existing['title'] ?? '')));
if ($title === '') {
throw new InvalidArgumentException('Titel ist erforderlich.');
}
$taskCategoryId = (int) ($dto['task_category_id'] ?? ($existing['task_category_id'] ?? 0));
if ($taskCategoryId <= 0) {
throw new InvalidArgumentException('Bitte Kategorie auswählen.');
}
$existingTaskCategoryId = isset($existing['task_category_id'])
? (int) $existing['task_category_id']
: null;
$this->ensureCategoryUsableForTask($taskCategoryId, $existingTaskCategoryId);
$frequencyType = (string) ($dto['frequency_type'] ?? ($existing['frequency_type'] ?? 'once'));
if (!in_array($frequencyType, ['once', 'recurring'], true)) {
$frequencyType = 'once';
}
$recurrenceBasis = (string) ($dto['recurrence_basis'] ?? ($existing['recurrence_basis'] ?? 'calendar'));
if (!in_array($recurrenceBasis, ['calendar', 'completion'], true)) {
$recurrenceBasis = 'calendar';
}
$recurrenceUnit = (string) ($dto['recurrence_unit'] ?? ($existing['recurrence_unit'] ?? ''));
if (!in_array($recurrenceUnit, ['day', 'week', 'month', 'year'], true)) {
$recurrenceUnit = null;
}
$recurrenceInterval = max(1, (int) ($dto['recurrence_interval'] ?? ($existing['recurrence_interval'] ?? 1)));
if ($frequencyType === 'once') {
$recurrenceBasis = 'calendar';
$recurrenceUnit = null;
$recurrenceInterval = null;
} elseif ($recurrenceUnit === null) {
throw new InvalidArgumentException('Bei wiederkehrenden Aufgaben muss eine Intervall-Einheit ausgewählt werden.');
}
$startDate = trim((string) ($dto['start_date'] ?? ($existing['start_date'] ?? date('Y-m-d'))));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
$startDate = date('Y-m-d');
}
$deadlineRuleType = (string) ($dto['deadline_rule_type'] ?? ($existing['deadline_rule_type'] ?? 'cycle_end'));
if (!in_array($deadlineRuleType, ['cycle_end', 'offset_days', 'weekday', 'monthday'], true)) {
$deadlineRuleType = 'cycle_end';
}
if ($frequencyType === 'once' && in_array($deadlineRuleType, ['weekday', 'monthday'], true)) {
throw new InvalidArgumentException('Für einmalige Aufgaben sind nur Fristen vom Typ "Zyklusende" oder "X Tage" erlaubt.');
}
if ($deadlineRuleType === 'weekday' && ($frequencyType !== 'recurring' || $recurrenceUnit !== 'week')) {
throw new InvalidArgumentException('Die Fristregel "Wochentag" ist nur für wöchentliche Aufgaben zulässig.');
}
if ($deadlineRuleType === 'monthday' && ($frequencyType !== 'recurring' || !in_array($recurrenceUnit, ['month', 'year'], true))) {
throw new InvalidArgumentException('Die Fristregel "Monatstag" ist nur für monatliche oder jährliche Aufgaben zulässig.');
}
$deadlineOffsetDays = null;
$deadlineWeekday = null;
$deadlineMonthday = null;
if ($deadlineRuleType === 'offset_days') {
$deadlineOffsetDays = $this->nullableInt($dto['deadline_offset_days'] ?? ($existing['deadline_offset_days'] ?? null));
if (($deadlineOffsetDays ?? 0) <= 0) {
throw new InvalidArgumentException('Bitte eine Frist in Tagen größer 0 angeben.');
}
}
if ($deadlineRuleType === 'weekday') {
$weekday = (int) ($dto['deadline_weekday'] ?? ($existing['deadline_weekday'] ?? 0));
if ($weekday < 1 || $weekday > 7) {
throw new InvalidArgumentException('Bitte einen gültigen Wochentag (1-7) wählen.');
}
$deadlineWeekday = $weekday;
}
if ($deadlineRuleType === 'monthday') {
$monthday = (int) ($dto['deadline_monthday'] ?? ($existing['deadline_monthday'] ?? 0));
if ($monthday < 1 || $monthday > 31) {
throw new InvalidArgumentException('Bitte einen gültigen Monatstag (1-31) wählen.');
}
$deadlineMonthday = $monthday;
}
$fulfillmentMode = (string) ($dto['fulfillment_mode'] ?? ($existing['fulfillment_mode'] ?? 'all'));
if (!in_array($fulfillmentMode, ['all', 'any'], true)) {
$fulfillmentMode = 'all';
}
$certificateEnabled = (int) ($dto['certificate_enabled'] ?? ($existing['certificate_enabled'] ?? 1)) === 1 ? 1 : 0;
$certificateTitle = trim((string) ($dto['certificate_title'] ?? ($existing['certificate_title'] ?? '')));
if ($certificateTitle === '') {
$certificateTitle = $title;
}
$certificateValidityMode = (string) ($dto['certificate_validity_mode'] ?? ($existing['certificate_validity_mode'] ?? ''));
if (!in_array($certificateValidityMode, ['cycle', 'days', 'permanent'], true)) {
$existingValidityDays = (int) ($existing['certificate_validity_days'] ?? 0);
if ($existingValidityDays > 0) {
$certificateValidityMode = 'days';
} elseif ($frequencyType === 'recurring') {
$certificateValidityMode = 'cycle';
} else {
$certificateValidityMode = 'permanent';
}
}
if ($frequencyType !== 'recurring' && $certificateValidityMode === 'cycle') {
$certificateValidityMode = 'permanent';
}
$certificateValidityDays = null;
if ($certificateValidityMode === 'days') {
$days = (int) ($dto['certificate_validity_days'] ?? ($existing['certificate_validity_days'] ?? 0));
if ($days <= 0 && $certificateEnabled === 1) {
throw new InvalidArgumentException('Bitte Gültigkeit in Tagen größer 0 angeben oder anderen Zertifikatsmodus wählen.');
}
if ($days > 0) {
$certificateValidityDays = $days;
}
}
$data = [
'title' => $title,
'description' => trim((string) ($dto['description'] ?? ($existing['description'] ?? ''))),
'task_category_id' => $taskCategoryId,
'frequency_type' => $frequencyType,
'recurrence_basis' => $recurrenceBasis,
'recurrence_unit' => $recurrenceUnit,
'recurrence_interval' => $recurrenceInterval,
'start_date' => $startDate,
'deadline_rule_type' => $deadlineRuleType,
'deadline_offset_days' => $deadlineOffsetDays,
'deadline_weekday' => $deadlineWeekday,
'deadline_monthday' => $deadlineMonthday,
'fulfillment_mode' => $fulfillmentMode,
'certificate_enabled' => $certificateEnabled,
'certificate_title' => $certificateTitle,
'certificate_validity_mode' => $certificateValidityMode,
'certificate_validity_days' => $certificateValidityDays,
'show_cycle_period' => (int) ($dto['show_cycle_period'] ?? ($existing['show_cycle_period'] ?? 0)) === 1 ? 1 : 0,
'active' => (int) ($dto['active'] ?? ($existing['active'] ?? 1)) === 1 ? 1 : 0,
'modified_by' => $actorId,
];
if ($isCreate) {
$data['created_by'] = $actorId;
} else {
$data['created_by'] = (int) ($existing['created_by'] ?? $actorId);
}
return $data;
}
private function ensureCategoryUsableForTask(int $taskCategoryId, ?int $existingTaskCategoryId): void
{
$category = $this->categoryRepo->findById($taskCategoryId);
if ($category === null) {
throw new InvalidArgumentException('Ausgewählte Kategorie existiert nicht.');
}
$isActive = (int) ($category['active'] ?? 0) === 1;
if ($isActive) {
return;
}
if ($existingTaskCategoryId !== null && $existingTaskCategoryId > 0 && $existingTaskCategoryId === $taskCategoryId) {
return;
}
throw new InvalidArgumentException('Ausgewählte Kategorie ist inaktiv und kann nicht neu zugewiesen werden.');
}
private function buildTaskConfigSnapshot(int $taskId): array
{
$task = $this->definitionRepo->findById($taskId);
if ($task === null) {
return [];
}
$rules = $this->ruleRepo->getTaskRuleBundle($taskId);
return [
'task' => $this->normalizeTaskDefinitionSnapshot($task),
'rules' => [
'methods' => $this->normalizeMethodsSnapshot(
is_array($rules['methods'] ?? null) ? $rules['methods'] : []
),
'target_rules' => $this->normalizeTargetRulesSnapshot(
is_array($rules['target_rules'] ?? null) ? $rules['target_rules'] : []
),
'target_overrides' => $this->normalizeTargetOverridesSnapshot(
is_array($rules['target_overrides'] ?? null) ? $rules['target_overrides'] : []
),
'reminder_policy' => $this->normalizeReminderPolicySnapshot(
is_array($rules['reminder_policy'] ?? null) ? $rules['reminder_policy'] : null
),
'responsible_contact_ids' => $this->normalizeResponsibleContactIdsSnapshot(
is_array($rules['responsible_contact_ids'] ?? null) ? $rules['responsible_contact_ids'] : []
),
],
];
}
private function normalizeResponsibleContactIdsSnapshot(array $contactIds): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $contactIds), static fn(int $id): bool => $id > 0)));
sort($ids, SORT_NUMERIC);
return $ids;
}
private function normalizeTaskDefinitionSnapshot(array $task): array
{
return [
'title' => trim((string) ($task['title'] ?? '')),
'description' => trim((string) ($task['description'] ?? '')),
'task_category_id' => (int) ($task['task_category_id'] ?? 0),
'frequency_type' => (string) ($task['frequency_type'] ?? 'once'),
'recurrence_basis' => (string) ($task['recurrence_basis'] ?? 'calendar'),
'recurrence_unit' => ($task['recurrence_unit'] ?? null) !== null ? (string) $task['recurrence_unit'] : null,
'recurrence_interval' => $this->nullableInt($task['recurrence_interval'] ?? null),
'start_date' => (string) ($task['start_date'] ?? ''),
'deadline_rule_type' => (string) ($task['deadline_rule_type'] ?? 'cycle_end'),
'deadline_offset_days' => $this->nullableInt($task['deadline_offset_days'] ?? null),
'deadline_weekday' => $this->nullableInt($task['deadline_weekday'] ?? null),
'deadline_monthday' => $this->nullableInt($task['deadline_monthday'] ?? null),
'fulfillment_mode' => (string) ($task['fulfillment_mode'] ?? 'all'),
'certificate_enabled' => (int) ($task['certificate_enabled'] ?? 0),
'certificate_title' => trim((string) ($task['certificate_title'] ?? '')),
'certificate_validity_mode' => (string) ($task['certificate_validity_mode'] ?? ''),
'certificate_validity_days' => $this->nullableInt($task['certificate_validity_days'] ?? null),
'show_cycle_period' => (int) ($task['show_cycle_period'] ?? 0),
'active' => (int) ($task['active'] ?? 0),
];
}
private function normalizeMethodsSnapshot(array $methods): array
{
$normalized = [];
foreach ($methods as $method) {
if (!is_array($method)) {
continue;
}
$methodType = trim((string) ($method['method_type'] ?? ''));
if ($methodType === '') {
continue;
}
$knowledgePostIds = is_array($method['knowledge_post_ids'] ?? null)
? array_map('intval', $method['knowledge_post_ids'])
: [];
if ($knowledgePostIds === []) {
foreach ((array) ($method['knowledge_posts'] ?? []) as $post) {
$postId = (int) (is_array($post) ? ($post['knowledge_post_id'] ?? 0) : $post);
if ($postId > 0) {
$knowledgePostIds[] = $postId;
}
}
}
$knowledgePostIds = array_values(array_unique($knowledgePostIds));
sort($knowledgePostIds, SORT_NUMERIC);
$formFieldKeys = is_array($method['form_field_keys'] ?? null)
? array_map('strval', $method['form_field_keys'])
: [];
if ($formFieldKeys === []) {
foreach ((array) ($method['form_fields'] ?? []) as $field) {
if (!is_array($field)) {
continue;
}
$fieldKey = trim((string) ($field['field_key'] ?? ''));
if ($fieldKey !== '') {
$formFieldKeys[] = $fieldKey;
}
}
}
$formFieldKeys = array_values(array_filter(array_map('trim', $formFieldKeys), static fn(string $value): bool => $value !== ''));
$formFieldKeys = array_values(array_unique($formFieldKeys));
sort($formFieldKeys, SORT_STRING);
$quizQuestionCount = (int) ($method['quiz_question_count'] ?? 0);
if ($quizQuestionCount <= 0) {
$quizQuestionCount = 0;
foreach ((array) ($method['quiz_questions'] ?? []) as $question) {
if (!is_array($question)) {
continue;
}
$questionText = trim((string) ($question['question_text'] ?? ''));
if ($questionText !== '') {
$quizQuestionCount++;
}
}
}
$normalized[$methodType] = [
'method_type' => $methodType,
'active' => (int) ($method['active'] ?? 1),
'is_required' => (int) ($method['is_required'] ?? 1),
'sort_order' => (int) ($method['sort_order'] ?? 0),
'settings' => $this->normalizeValueRecursive(is_array($method['settings'] ?? null) ? $method['settings'] : []),
'knowledge_post_ids' => $knowledgePostIds,
'form_field_keys' => $formFieldKeys,
'quiz_question_count' => $quizQuestionCount,
];
}
ksort($normalized, SORT_STRING);
return array_values($normalized);
}
private function normalizeTargetRulesSnapshot(array $targetRules): array
{
$normalized = [];
foreach ($targetRules as $rule) {
if (!is_array($rule)) {
continue;
}
$entry = [
'main_mandant_id' => (int) ($rule['main_mandant_id'] ?? 0),
'main_department_id' => (int) ($rule['main_department_id'] ?? 0),
'main_role_id' => (int) ($rule['main_role_id'] ?? 0),
'active' => (int) ($rule['active'] ?? 1),
];
$key = $entry['main_mandant_id'] . ':'
. $entry['main_department_id'] . ':'
. $entry['main_role_id'] . ':'
. $entry['active'];
$normalized[$key] = $entry;
}
ksort($normalized, SORT_STRING);
return array_values($normalized);
}
private function normalizeTargetOverridesSnapshot(array $targetOverrides): array
{
$normalized = [];
foreach ($targetOverrides as $override) {
if (!is_array($override)) {
continue;
}
$type = (string) ($override['override_type'] ?? '');
if (!in_array($type, ['include', 'exclude'], true)) {
continue;
}
$contactId = (int) ($override['main_contact_id'] ?? 0);
if ($contactId <= 0) {
continue;
}
$key = $type . ':' . $contactId;
$normalized[$key] = [
'override_type' => $type,
'main_contact_id' => $contactId,
'reason' => trim((string) ($override['reason'] ?? '')),
];
}
ksort($normalized, SORT_STRING);
return array_values($normalized);
}
private function normalizeReminderPolicySnapshot(?array $policy): array
{
if (!is_array($policy)) {
return [];
}
return [
'base_mode' => (string) ($policy['base_mode'] ?? 'due_date'),
'first_escalation_days' => (int) ($policy['first_escalation_days'] ?? 0),
'step_days' => (int) ($policy['step_days'] ?? 0),
'max_level' => (int) ($policy['max_level'] ?? 0),
'active' => (int) ($policy['active'] ?? 1),
];
}
private function buildSnapshotDiff(array $beforeSnapshot, array $afterSnapshot): array
{
$beforeTask = is_array($beforeSnapshot['task'] ?? null) ? $beforeSnapshot['task'] : [];
$afterTask = is_array($afterSnapshot['task'] ?? null) ? $afterSnapshot['task'] : [];
$beforeRules = is_array($beforeSnapshot['rules'] ?? null) ? $beforeSnapshot['rules'] : [];
$afterRules = is_array($afterSnapshot['rules'] ?? null) ? $afterSnapshot['rules'] : [];
$beforeTargetRuleMap = $this->buildTargetRuleMap(
$this->normalizeTargetRulesSnapshot(is_array($beforeRules['target_rules'] ?? null) ? $beforeRules['target_rules'] : [])
);
$afterTargetRuleMap = $this->buildTargetRuleMap(
$this->normalizeTargetRulesSnapshot(is_array($afterRules['target_rules'] ?? null) ? $afterRules['target_rules'] : [])
);
$beforeTargetOverrideMap = $this->buildTargetOverrideMap(
$this->normalizeTargetOverridesSnapshot(is_array($beforeRules['target_overrides'] ?? null) ? $beforeRules['target_overrides'] : [])
);
$afterTargetOverrideMap = $this->buildTargetOverrideMap(
$this->normalizeTargetOverridesSnapshot(is_array($afterRules['target_overrides'] ?? null) ? $afterRules['target_overrides'] : [])
);
$beforeMethodMap = $this->buildMethodMap(
$this->normalizeMethodsSnapshot(is_array($beforeRules['methods'] ?? null) ? $beforeRules['methods'] : [])
);
$afterMethodMap = $this->buildMethodMap(
$this->normalizeMethodsSnapshot(is_array($afterRules['methods'] ?? null) ? $afterRules['methods'] : [])
);
$targetRuleAddedKeys = array_values(array_diff(array_keys($afterTargetRuleMap), array_keys($beforeTargetRuleMap)));
$targetRuleRemovedKeys = array_values(array_diff(array_keys($beforeTargetRuleMap), array_keys($afterTargetRuleMap)));
sort($targetRuleAddedKeys, SORT_STRING);
sort($targetRuleRemovedKeys, SORT_STRING);
$targetOverrideAddedKeys = array_values(array_diff(array_keys($afterTargetOverrideMap), array_keys($beforeTargetOverrideMap)));
$targetOverrideRemovedKeys = array_values(array_diff(array_keys($beforeTargetOverrideMap), array_keys($afterTargetOverrideMap)));
sort($targetOverrideAddedKeys, SORT_STRING);
sort($targetOverrideRemovedKeys, SORT_STRING);
$targetOverridesReasonChanged = [];
$targetOverrideCommonKeys = array_values(array_intersect(array_keys($beforeTargetOverrideMap), array_keys($afterTargetOverrideMap)));
sort($targetOverrideCommonKeys, SORT_STRING);
foreach ($targetOverrideCommonKeys as $key) {
$beforeReason = (string) ($beforeTargetOverrideMap[$key]['reason'] ?? '');
$afterReason = (string) ($afterTargetOverrideMap[$key]['reason'] ?? '');
if ($beforeReason === $afterReason) {
continue;
}
$targetOverridesReasonChanged[] = [
'override_type' => (string) ($afterTargetOverrideMap[$key]['override_type'] ?? ''),
'main_contact_id' => (int) ($afterTargetOverrideMap[$key]['main_contact_id'] ?? 0),
'before_reason' => $beforeReason,
'after_reason' => $afterReason,
];
}
$methodAddedKeys = array_values(array_diff(array_keys($afterMethodMap), array_keys($beforeMethodMap)));
$methodRemovedKeys = array_values(array_diff(array_keys($beforeMethodMap), array_keys($afterMethodMap)));
sort($methodAddedKeys, SORT_STRING);
sort($methodRemovedKeys, SORT_STRING);
$methodsChanged = [];
$methodCommonKeys = array_values(array_intersect(array_keys($beforeMethodMap), array_keys($afterMethodMap)));
sort($methodCommonKeys, SORT_STRING);
foreach ($methodCommonKeys as $key) {
$beforeValue = $this->normalizeValueRecursive($beforeMethodMap[$key]);
$afterValue = $this->normalizeValueRecursive($afterMethodMap[$key]);
if ($beforeValue === $afterValue) {
continue;
}
$methodsChanged[] = [
'method_type' => $key,
'before' => $beforeMethodMap[$key],
'after' => $afterMethodMap[$key],
];
}
return [
'task_fields_changed' => $this->buildFieldChanges($beforeTask, $afterTask),
'methods_added' => array_values(array_map(static fn(string $key): array => $afterMethodMap[$key], $methodAddedKeys)),
'methods_removed' => array_values(array_map(static fn(string $key): array => $beforeMethodMap[$key], $methodRemovedKeys)),
'methods_changed' => $methodsChanged,
'target_rules_added' => array_values(array_map(static fn(string $key): array => $afterTargetRuleMap[$key], $targetRuleAddedKeys)),
'target_rules_removed' => array_values(array_map(static fn(string $key): array => $beforeTargetRuleMap[$key], $targetRuleRemovedKeys)),
'target_overrides_added' => array_values(array_map(static fn(string $key): array => $afterTargetOverrideMap[$key], $targetOverrideAddedKeys)),
'target_overrides_removed' => array_values(array_map(static fn(string $key): array => $beforeTargetOverrideMap[$key], $targetOverrideRemovedKeys)),
'target_overrides_reason_changed' => $targetOverridesReasonChanged,
'reminder_policy_changed' => $this->buildFieldChanges(
$this->normalizeReminderPolicySnapshot(is_array($beforeRules['reminder_policy'] ?? null) ? $beforeRules['reminder_policy'] : null),
$this->normalizeReminderPolicySnapshot(is_array($afterRules['reminder_policy'] ?? null) ? $afterRules['reminder_policy'] : null)
),
];
}
private function buildFieldChanges(array $before, array $after): array
{
$keys = array_values(array_unique(array_merge(array_keys($before), array_keys($after))));
sort($keys, SORT_STRING);
$changes = [];
foreach ($keys as $key) {
$beforeValue = $this->normalizeValueRecursive($before[$key] ?? null);
$afterValue = $this->normalizeValueRecursive($after[$key] ?? null);
if ($beforeValue === $afterValue) {
continue;
}
$changes[$key] = [
'before' => $before[$key] ?? null,
'after' => $after[$key] ?? null,
];
}
return $changes;
}
private function buildTargetRuleMap(array $rules): array
{
$map = [];
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
$entry = [
'main_mandant_id' => (int) ($rule['main_mandant_id'] ?? 0),
'main_department_id' => (int) ($rule['main_department_id'] ?? 0),
'main_role_id' => (int) ($rule['main_role_id'] ?? 0),
'active' => (int) ($rule['active'] ?? 1),
];
$key = $entry['main_mandant_id'] . ':'
. $entry['main_department_id'] . ':'
. $entry['main_role_id'] . ':'
. $entry['active'];
$map[$key] = $entry;
}
ksort($map, SORT_STRING);
return $map;
}
private function buildTargetOverrideMap(array $overrides): array
{
$map = [];
foreach ($overrides as $override) {
if (!is_array($override)) {
continue;
}
$type = (string) ($override['override_type'] ?? '');
$contactId = (int) ($override['main_contact_id'] ?? 0);
if (!in_array($type, ['include', 'exclude'], true) || $contactId <= 0) {
continue;
}
$key = $type . ':' . $contactId;
$map[$key] = [
'override_type' => $type,
'main_contact_id' => $contactId,
'reason' => trim((string) ($override['reason'] ?? '')),
];
}
ksort($map, SORT_STRING);
return $map;
}
private function buildMethodMap(array $methods): array
{
$map = [];
foreach ($methods as $method) {
if (!is_array($method)) {
continue;
}
$methodType = trim((string) ($method['method_type'] ?? ''));
if ($methodType === '') {
continue;
}
$map[$methodType] = $method;
}
ksort($map, SORT_STRING);
return $map;
}
private function normalizeValueRecursive($value)
{
if (!is_array($value)) {
return $value;
}
if ($this->isListArray($value)) {
$normalized = [];
foreach ($value as $item) {
$normalized[] = $this->normalizeValueRecursive($item);
}
return $normalized;
}
$normalized = [];
$keys = array_keys($value);
sort($keys, SORT_STRING);
foreach ($keys as $key) {
$normalized[$key] = $this->normalizeValueRecursive($value[$key]);
}
return $normalized;
}
private function isListArray(array $value): bool
{
$expected = 0;
foreach (array_keys($value) as $key) {
if ($key !== $expected) {
return false;
}
$expected++;
}
return true;
}
private function nullableInt($value): ?int
{
if ($value === null || $value === '' || (int) $value <= 0) {
return null;
}
return (int) $value;
}
}