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>
1226 lines
48 KiB
PHP
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;
|
|
}
|
|
}
|