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

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;
}
}