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>
432 lines
13 KiB
PHP
432 lines
13 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
class TaskCategoryService
|
|
{
|
|
private TaskCategoryRepository $categoryRepo;
|
|
private TaskCategoryChangeLogRepository $changeLogRepo;
|
|
|
|
public function __construct(
|
|
TaskCategoryRepository $categoryRepo,
|
|
TaskCategoryChangeLogRepository $changeLogRepo
|
|
) {
|
|
$this->categoryRepo = $categoryRepo;
|
|
$this->changeLogRepo = $changeLogRepo;
|
|
}
|
|
|
|
public function listForAdmin(string $search = '', string $state = 'active'): array
|
|
{
|
|
return $this->categoryRepo->listForAdmin($search, $state);
|
|
}
|
|
|
|
public function getById(int $categoryId): ?array
|
|
{
|
|
return $this->categoryRepo->findById($categoryId);
|
|
}
|
|
|
|
public function upsert(int $categoryId, string $name, int $actorId, ?int $activeOverride = null): array
|
|
{
|
|
$name = $this->normalizeName($name);
|
|
|
|
if ($categoryId > 0) {
|
|
return $this->updateCategory($categoryId, $name, $actorId, $activeOverride);
|
|
}
|
|
|
|
return $this->createCategory($name, $actorId);
|
|
}
|
|
|
|
public function setActive(int $categoryId, int $active, int $actorId): array
|
|
{
|
|
$active = $active === 1 ? 1 : 0;
|
|
$category = $this->categoryRepo->findById($categoryId);
|
|
if ($category === null) {
|
|
throw new RuntimeException('Kategorie nicht gefunden.');
|
|
}
|
|
|
|
if ($active === 0 && $this->isProtectedCategory($category)) {
|
|
throw new RuntimeException('Die Standardkategorie "Allgemein" kann nicht deaktiviert werden.');
|
|
}
|
|
|
|
if ((int) ($category['active'] ?? 0) === $active) {
|
|
return [
|
|
'category' => $this->snapshot($category),
|
|
'changed' => false,
|
|
'action' => $active === 1 ? 'category_activate' : 'category_deactivate',
|
|
];
|
|
}
|
|
|
|
TaskCertDb::beginTransaction();
|
|
try {
|
|
$before = $this->snapshot($category);
|
|
|
|
$this->categoryRepo->setActive($categoryId, $active);
|
|
$afterCategory = $this->categoryRepo->findById($categoryId);
|
|
if ($afterCategory === null) {
|
|
throw new RuntimeException('Kategorie konnte nicht geladen werden.');
|
|
}
|
|
|
|
$after = $this->snapshot($afterCategory);
|
|
$action = $active === 1 ? 'category_activate' : 'category_deactivate';
|
|
|
|
$this->changeLogRepo->create(
|
|
$categoryId,
|
|
$action,
|
|
$actorId,
|
|
$before,
|
|
$after,
|
|
[
|
|
'field_changes' => $this->buildFieldDiff($before, $after),
|
|
]
|
|
);
|
|
|
|
TaskCertDb::commit();
|
|
|
|
return [
|
|
'category' => $after,
|
|
'changed' => true,
|
|
'action' => $action,
|
|
];
|
|
} catch (Throwable $throwable) {
|
|
TaskCertDb::rollback();
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
public function deleteCategory(int $categoryId, int $actorId): array
|
|
{
|
|
$category = $this->categoryRepo->findById($categoryId);
|
|
if ($category === null) {
|
|
throw new RuntimeException('Kategorie nicht gefunden.');
|
|
}
|
|
|
|
if ($this->isProtectedCategory($category)) {
|
|
throw new RuntimeException('Die Standardkategorie "Allgemein" kann nicht gelöscht werden.');
|
|
}
|
|
|
|
$taskCount = $this->categoryRepo->countTasks($categoryId);
|
|
if ($taskCount > 0) {
|
|
throw new RuntimeException('Kategorie kann nicht gelöscht werden, solange Aufgaben zugeordnet sind.');
|
|
}
|
|
|
|
TaskCertDb::beginTransaction();
|
|
try {
|
|
$before = $this->snapshot($category);
|
|
$deleted = $this->categoryRepo->delete($categoryId);
|
|
if ($deleted <= 0) {
|
|
throw new RuntimeException('Kategorie konnte nicht gelöscht werden.');
|
|
}
|
|
|
|
$this->changeLogRepo->create(
|
|
$categoryId,
|
|
'category_delete',
|
|
$actorId,
|
|
$before,
|
|
null,
|
|
[
|
|
'deleted' => true,
|
|
'task_count' => $taskCount,
|
|
]
|
|
);
|
|
|
|
TaskCertDb::commit();
|
|
|
|
return [
|
|
'deleted' => true,
|
|
'category_id' => $categoryId,
|
|
'task_count' => $taskCount,
|
|
'before' => $before,
|
|
];
|
|
} catch (Throwable $throwable) {
|
|
TaskCertDb::rollback();
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
public function ensureCategoryUsableForTask(int $categoryId, ?int $currentTaskCategoryId = null): array
|
|
{
|
|
if ($categoryId <= 0) {
|
|
throw new InvalidArgumentException('Bitte Kategorie auswählen.');
|
|
}
|
|
|
|
$category = $this->categoryRepo->findById($categoryId);
|
|
if ($category === null) {
|
|
throw new InvalidArgumentException('Ausgewählte Kategorie existiert nicht.');
|
|
}
|
|
|
|
$isActive = (int) ($category['active'] ?? 0) === 1;
|
|
if ($isActive) {
|
|
return $category;
|
|
}
|
|
|
|
if ($currentTaskCategoryId !== null && $currentTaskCategoryId > 0 && $currentTaskCategoryId === $categoryId) {
|
|
return $category;
|
|
}
|
|
|
|
throw new InvalidArgumentException('Ausgewählte Kategorie ist inaktiv und kann nicht neu zugewiesen werden.');
|
|
}
|
|
|
|
private function createCategory(string $name, int $actorId): array
|
|
{
|
|
$this->assertUniqueName($name, null);
|
|
|
|
$slug = $this->createUniqueSlug($name);
|
|
$sortOrder = $this->categoryRepo->nextSortOrder(10);
|
|
|
|
TaskCertDb::beginTransaction();
|
|
try {
|
|
$categoryId = $this->categoryRepo->create([
|
|
'name' => $name,
|
|
'slug' => $slug,
|
|
'sort_order' => $sortOrder,
|
|
'active' => 1,
|
|
]);
|
|
|
|
$created = $this->categoryRepo->findById($categoryId);
|
|
if ($created === null) {
|
|
throw new RuntimeException('Kategorie konnte nicht geladen werden.');
|
|
}
|
|
|
|
$after = $this->snapshot($created);
|
|
|
|
$this->changeLogRepo->create(
|
|
$categoryId,
|
|
'category_create',
|
|
$actorId,
|
|
null,
|
|
$after,
|
|
[
|
|
'created' => true,
|
|
]
|
|
);
|
|
|
|
TaskCertDb::commit();
|
|
|
|
return [
|
|
'category' => $after,
|
|
'created' => true,
|
|
'action' => 'category_create',
|
|
];
|
|
} catch (Throwable $throwable) {
|
|
TaskCertDb::rollback();
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
private function updateCategory(int $categoryId, string $name, int $actorId, ?int $activeOverride = null): array
|
|
{
|
|
$current = $this->categoryRepo->findById($categoryId);
|
|
if ($current === null) {
|
|
throw new RuntimeException('Kategorie nicht gefunden.');
|
|
}
|
|
|
|
$this->assertUniqueName($name, $categoryId);
|
|
|
|
if ($activeOverride !== null && !in_array($activeOverride, [0, 1], true)) {
|
|
throw new InvalidArgumentException('Ungültiger Statuswert.');
|
|
}
|
|
|
|
if ($activeOverride === 0 && $this->isProtectedCategory($current)) {
|
|
throw new RuntimeException('Die Standardkategorie "Allgemein" kann nicht deaktiviert werden.');
|
|
}
|
|
|
|
$before = $this->snapshot($current);
|
|
$target = [
|
|
'id' => (int) ($current['id'] ?? 0),
|
|
'name' => $name,
|
|
'slug' => (string) ($current['slug'] ?? ''),
|
|
'sort_order' => (int) ($current['sort_order'] ?? 0),
|
|
'active' => $activeOverride !== null ? $activeOverride : (int) ($current['active'] ?? 1),
|
|
];
|
|
|
|
if (
|
|
$before['name'] === $target['name']
|
|
&& $before['sort_order'] === $target['sort_order']
|
|
&& $before['active'] === $target['active']
|
|
) {
|
|
return [
|
|
'category' => $before,
|
|
'created' => false,
|
|
'changed' => false,
|
|
'action' => 'category_update',
|
|
];
|
|
}
|
|
|
|
TaskCertDb::beginTransaction();
|
|
try {
|
|
$this->categoryRepo->update($categoryId, [
|
|
'name' => $target['name'],
|
|
'sort_order' => $target['sort_order'],
|
|
'active' => $target['active'],
|
|
]);
|
|
|
|
$updated = $this->categoryRepo->findById($categoryId);
|
|
if ($updated === null) {
|
|
throw new RuntimeException('Kategorie konnte nicht geladen werden.');
|
|
}
|
|
|
|
$after = $this->snapshot($updated);
|
|
$fieldChanges = $this->buildFieldDiff($before, $after);
|
|
|
|
$this->changeLogRepo->create(
|
|
$categoryId,
|
|
'category_update',
|
|
$actorId,
|
|
$before,
|
|
$after,
|
|
[
|
|
'field_changes' => $fieldChanges,
|
|
]
|
|
);
|
|
|
|
TaskCertDb::commit();
|
|
|
|
return [
|
|
'category' => $after,
|
|
'created' => false,
|
|
'changed' => true,
|
|
'action' => 'category_update',
|
|
];
|
|
} catch (Throwable $throwable) {
|
|
TaskCertDb::rollback();
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
private function normalizeName(string $name): string
|
|
{
|
|
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
|
|
if ($name === '') {
|
|
throw new InvalidArgumentException('Name ist erforderlich.');
|
|
}
|
|
|
|
if (function_exists('mb_strlen')) {
|
|
if (mb_strlen($name) > 120) {
|
|
throw new InvalidArgumentException('Name darf maximal 120 Zeichen lang sein.');
|
|
}
|
|
} elseif (strlen($name) > 120) {
|
|
throw new InvalidArgumentException('Name darf maximal 120 Zeichen lang sein.');
|
|
}
|
|
|
|
return $name;
|
|
}
|
|
|
|
private function assertUniqueName(string $name, ?int $ignoreCategoryId): void
|
|
{
|
|
$normalized = $this->normalizeNameForCompare($name);
|
|
$existing = $this->categoryRepo->findByNormalizedName($normalized);
|
|
if ($existing === null) {
|
|
return;
|
|
}
|
|
|
|
$existingId = (int) ($existing['id'] ?? 0);
|
|
if ($ignoreCategoryId !== null && $existingId === $ignoreCategoryId) {
|
|
return;
|
|
}
|
|
|
|
throw new InvalidArgumentException('Eine Kategorie mit diesem Namen existiert bereits.');
|
|
}
|
|
|
|
private function normalizeNameForCompare(string $name): string
|
|
{
|
|
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
|
|
if (function_exists('mb_strtolower')) {
|
|
return mb_strtolower($name, 'UTF-8');
|
|
}
|
|
|
|
return strtolower($name);
|
|
}
|
|
|
|
private function createUniqueSlug(string $name): string
|
|
{
|
|
$baseSlug = $this->slugify($name);
|
|
if ($baseSlug === '') {
|
|
$baseSlug = 'kategorie';
|
|
}
|
|
|
|
$slug = $baseSlug;
|
|
$suffix = 2;
|
|
|
|
while ($this->categoryRepo->findBySlug($slug) !== null) {
|
|
$slug = $baseSlug . '-' . $suffix;
|
|
$suffix++;
|
|
}
|
|
|
|
return $slug;
|
|
}
|
|
|
|
private function slugify(string $value): string
|
|
{
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
$replaceMap = [
|
|
'Ä' => 'ae',
|
|
'Ö' => 'oe',
|
|
'Ü' => 'ue',
|
|
'ä' => 'ae',
|
|
'ö' => 'oe',
|
|
'ü' => 'ue',
|
|
'ß' => 'ss',
|
|
];
|
|
$value = strtr($value, $replaceMap);
|
|
|
|
if (function_exists('iconv')) {
|
|
$converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
|
|
if (is_string($converted) && $converted !== '') {
|
|
$value = $converted;
|
|
}
|
|
}
|
|
|
|
$value = strtolower($value);
|
|
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
|
|
$value = trim($value, '-');
|
|
|
|
if (function_exists('mb_strlen') && function_exists('mb_substr') && mb_strlen($value) > 140) {
|
|
$value = mb_substr($value, 0, 140);
|
|
$value = rtrim($value, '-');
|
|
} elseif (strlen($value) > 140) {
|
|
$value = substr($value, 0, 140);
|
|
$value = rtrim($value, '-');
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function isProtectedCategory(array $category): bool
|
|
{
|
|
return strtolower(trim((string) ($category['slug'] ?? ''))) === 'allgemein';
|
|
}
|
|
|
|
private function snapshot(array $category): array
|
|
{
|
|
return [
|
|
'id' => (int) ($category['id'] ?? 0),
|
|
'name' => trim((string) ($category['name'] ?? '')),
|
|
'slug' => trim((string) ($category['slug'] ?? '')),
|
|
'sort_order' => (int) ($category['sort_order'] ?? 0),
|
|
'active' => (int) ($category['active'] ?? 0),
|
|
];
|
|
}
|
|
|
|
private function buildFieldDiff(array $before, array $after): array
|
|
{
|
|
$diff = [];
|
|
foreach (['name', 'slug', 'sort_order', 'active'] as $field) {
|
|
$beforeValue = $before[$field] ?? null;
|
|
$afterValue = $after[$field] ?? null;
|
|
if ($beforeValue === $afterValue) {
|
|
continue;
|
|
}
|
|
|
|
$diff[$field] = [
|
|
'before' => $beforeValue,
|
|
'after' => $afterValue,
|
|
];
|
|
}
|
|
|
|
return $diff;
|
|
}
|
|
}
|