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>
This commit is contained in:
2026-06-22 09:20:02 +02:00
parent 2e75ff7d59
commit 743476f64a
136 changed files with 38100 additions and 627 deletions

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
final class TaskCertCronRuntime
{
public static function parseOptions(array $argv, bool $allowTaskId = true): array
{
$options = [
'apply' => false,
'dry_run' => false,
'json' => false,
'no_lock' => false,
'task_id' => null,
'help' => false,
];
$count = count($argv);
for ($i = 1; $i < $count; $i++) {
$arg = (string) $argv[$i];
if ($arg === '--apply') {
$options['apply'] = true;
continue;
}
if ($arg === '--dry-run') {
$options['dry_run'] = true;
continue;
}
if ($arg === '--json') {
$options['json'] = true;
continue;
}
if ($arg === '--no-lock') {
$options['no_lock'] = true;
continue;
}
if ($arg === '--help') {
$options['help'] = true;
continue;
}
if (strpos($arg, '--task_id=') === 0) {
if (!$allowTaskId) {
throw new InvalidArgumentException('--task_id wird für diesen Job nicht unterstützt.');
}
$raw = trim(substr($arg, strlen('--task_id=')));
if ($raw === '' || !ctype_digit($raw)) {
throw new InvalidArgumentException('Ungültiger Wert für --task_id.');
}
$options['task_id'] = (int) $raw;
continue;
}
if ($arg === '--task_id') {
if (!$allowTaskId) {
throw new InvalidArgumentException('--task_id wird für diesen Job nicht unterstützt.');
}
$next = $argv[$i + 1] ?? null;
if (!is_string($next) || trim($next) === '' || !ctype_digit(trim($next))) {
throw new InvalidArgumentException('Ungültiger Wert für --task_id.');
}
$options['task_id'] = (int) trim($next);
$i++;
continue;
}
throw new InvalidArgumentException('Unbekannte Option: ' . $arg);
}
if ($options['apply'] && $options['dry_run']) {
throw new InvalidArgumentException('--apply und --dry-run können nicht gleichzeitig gesetzt werden.');
}
if ($options['task_id'] !== null && (int) $options['task_id'] <= 0) {
throw new InvalidArgumentException('--task_id muss > 0 sein.');
}
return $options;
}
public static function runWithHandling(string $jobName, array $options, callable $runner): int
{
$startedAt = microtime(true);
$runId = self::buildRunId($jobName);
$mode = $options['apply'] ? 'apply' : 'dry_run';
$lockHandle = null;
$basePayload = [
'timestamp' => date('c'),
'job' => $jobName,
'job_run_id' => $runId,
'mode' => $mode,
'task_id' => $options['task_id'],
];
try {
if (!$options['no_lock']) {
$lockResult = self::acquireLock($jobName);
$lockHandle = $lockResult['handle'] ?? null;
$acquired = (bool) ($lockResult['acquired'] ?? false);
if (!$acquired) {
$payload = $basePayload + [
'success' => false,
'skipped' => true,
'skip_reason' => 'lock_active',
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
];
self::writeCronLog($payload);
if ((bool) ($options['json'] ?? false)) {
self::emitJson($payload);
} else {
fwrite(STDOUT, '[SKIP] ' . $jobName . ': Lock aktiv, Job wird übersprungen.' . PHP_EOL);
}
return 3;
}
}
$context = [
'job_run_id' => $runId,
'mode' => $mode,
'task_id' => $options['task_id'],
];
$summary = $runner($context);
if (!is_array($summary)) {
$summary = [];
}
$payload = $basePayload + [
'success' => true,
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
'summary' => $summary,
];
self::writeCronLog($payload);
if ((bool) ($options['json'] ?? false)) {
self::emitJson($payload);
} else {
fwrite(STDOUT, '[OK] ' . $jobName . ' (' . $mode . ') abgeschlossen in ' . $payload['duration_ms'] . 'ms.' . PHP_EOL);
}
return 0;
} catch (Throwable $throwable) {
$payload = $basePayload + [
'success' => false,
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
'error' => $throwable->getMessage(),
];
self::writeCronLog($payload);
if ((bool) ($options['json'] ?? false)) {
self::emitJson($payload);
} else {
fwrite(STDERR, '[ERROR] ' . $jobName . ' (' . $mode . '): ' . $throwable->getMessage() . PHP_EOL);
}
return 1;
} finally {
self::releaseLock($lockHandle);
}
}
public static function printUsage(string $usage): void
{
fwrite(STDOUT, trim($usage) . PHP_EOL);
}
public static function printOptionError(string $message, string $usage): void
{
fwrite(STDERR, $message . PHP_EOL . PHP_EOL . trim($usage) . PHP_EOL);
}
private static function acquireLock(string $jobName): array
{
$lockDir = self::logsDir() . '/locks';
if (!is_dir($lockDir)) {
@mkdir($lockDir, 0775, true);
}
$lockPath = $lockDir . '/task_cert_' . $jobName . '.lock';
$handle = @fopen($lockPath, 'c+');
if ($handle === false) {
throw new RuntimeException('Lock-Datei konnte nicht geöffnet werden: ' . $lockPath);
}
$acquired = flock($handle, LOCK_EX | LOCK_NB);
if (!$acquired) {
return [
'acquired' => false,
'handle' => $handle,
];
}
$meta = [
'pid' => getmypid(),
'started_at' => date('c'),
];
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
fflush($handle);
return [
'acquired' => true,
'handle' => $handle,
];
}
private static function releaseLock($lockHandle): void
{
if (!is_resource($lockHandle)) {
return;
}
@flock($lockHandle, LOCK_UN);
@fclose($lockHandle);
}
private static function writeCronLog(array $payload): void
{
$logDir = self::logsDir();
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$file = $logDir . '/task_cert_cron.log';
$line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($line) || $line === '') {
$line = json_encode([
'timestamp' => date('c'),
'success' => false,
'error' => 'cron_log_encode_failed',
]);
}
@file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
}
private static function emitJson(array $payload): void
{
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
private static function logsDir(): string
{
return dirname(__DIR__) . '/logs';
}
private static function buildRunId(string $jobName): string
{
try {
$entropy = bin2hex(random_bytes(6));
} catch (Throwable $throwable) {
$entropy = substr(sha1((string) microtime(true)), 0, 12);
}
return $jobName . '-' . date('YmdHis') . '-' . $entropy;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/CronRuntime.php';
$usage = <<<TXT
Usage:
php cli/task_cert_materialize_assignments.php [--no-lock] [--json]
Options:
--no-lock Locking deaktivieren (nur Debug)
--json JSON-Ausgabe zusätzlich auf stdout
--help Diese Hilfe anzeigen
TXT;
try {
$options = TaskCertCronRuntime::parseOptions($argv, false);
} catch (InvalidArgumentException $exception) {
TaskCertCronRuntime::printOptionError($exception->getMessage(), $usage);
exit(2);
}
if ((bool) ($options['help'] ?? false)) {
TaskCertCronRuntime::printUsage($usage);
exit(0);
}
$options['apply'] = true;
$exitCode = TaskCertCronRuntime::runWithHandling(
'materialize_assignments',
$options,
static function (): array {
require_once dirname(__DIR__) . '/bootstrap.php';
$services = task_cert_services();
$resolver = $services['assignmentResolverService'] ?? null;
if (!$resolver instanceof AssignmentResolverService) {
throw new RuntimeException('AssignmentResolverService nicht verfügbar.');
}
$count = $resolver->materializeAssignmentsForAllActiveTasks();
CronHeartbeat::record('materialize_assignments', ['materialized' => $count]);
return [
'job' => 'materialize_assignments',
'materialized' => $count,
];
}
);
exit($exitCode);

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/CronRuntime.php';
$usage = <<<TXT
Usage:
php cli/task_cert_recalculate_escalations.php [--apply|--dry-run] [--task_id=<id>] [--no-lock] [--json]
Options:
--apply Mutierender Lauf (Status/Eskalationslevel schreiben)
--dry-run Nur Vorschau, keine DB-Änderungen
--task_id Optional: nur eine Aufgabe verarbeiten
--no-lock Locking deaktivieren (nur Debug)
--json JSON-Ausgabe zusätzlich auf stdout
--help Diese Hilfe anzeigen
TXT;
try {
$options = TaskCertCronRuntime::parseOptions($argv, true);
} catch (InvalidArgumentException $exception) {
TaskCertCronRuntime::printOptionError($exception->getMessage(), $usage);
exit(2);
}
if ((bool) ($options['help'] ?? false)) {
TaskCertCronRuntime::printUsage($usage);
exit(0);
}
$exitCode = TaskCertCronRuntime::runWithHandling(
'recalculate_escalations',
$options,
static function () use ($options): array {
require_once dirname(__DIR__) . '/bootstrap.php';
$services = task_cert_services();
$escalationService = $services['escalationService'] ?? null;
if (!$escalationService instanceof EscalationService) {
throw new RuntimeException('EscalationService nicht verfügbar.');
}
$apply = (bool) ($options['apply'] ?? false);
$taskId = (int) ($options['task_id'] ?? 0);
$summary = $escalationService->recalculateEscalationsDetailed(
$apply,
$taskId > 0 ? $taskId : null
);
if ($apply) {
CronHeartbeat::record('recalculate_escalations', [
'changed' => (int) ($summary['changed_assignments'] ?? 0),
]);
}
return $summary;
}
);
exit($exitCode);

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/CronRuntime.php';
$usage = <<<TXT
Usage:
php cli/task_cert_sync_assignments.php [--apply|--dry-run] [--task_id=<id>] [--no-lock] [--json]
Options:
--apply Mutierender Lauf (Zuweisungen anlegen/entfernen)
--dry-run Nur Vorschau, keine DB-Änderungen
--task_id Optional: nur eine Aufgabe verarbeiten
--no-lock Locking deaktivieren (nur Debug)
--json JSON-Ausgabe zusätzlich auf stdout
--help Diese Hilfe anzeigen
TXT;
try {
$options = TaskCertCronRuntime::parseOptions($argv, true);
} catch (InvalidArgumentException $exception) {
TaskCertCronRuntime::printOptionError($exception->getMessage(), $usage);
exit(2);
}
if ((bool) ($options['help'] ?? false)) {
TaskCertCronRuntime::printUsage($usage);
exit(0);
}
$exitCode = TaskCertCronRuntime::runWithHandling(
'sync_assignments',
$options,
static function (array $context) use ($options): array {
require_once dirname(__DIR__) . '/bootstrap.php';
$services = task_cert_services();
$resolver = $services['assignmentResolverService'] ?? null;
if (!$resolver instanceof AssignmentResolverService) {
throw new RuntimeException('AssignmentResolverService nicht verfügbar.');
}
$apply = (bool) ($options['apply'] ?? false);
$taskId = (int) ($options['task_id'] ?? 0);
$auditContext = [
'changed_by' => 0,
'trigger' => 'cron_cli',
'job_run_id' => (string) ($context['job_run_id'] ?? ''),
];
if ($taskId > 0) {
$results = [$resolver->syncAssignmentsForTask($taskId, $apply, 'current_cycle', $auditContext)];
} else {
$results = $resolver->syncAssignmentsForAllActiveTasks($apply, 'current_cycle', $auditContext);
}
$totals = [
'task_count' => 0,
'target_count' => 0,
'existing_count' => 0,
'create_count' => 0,
'update_count' => 0,
'stale_count' => 0,
'protected_completed_count' => 0,
'protected_certificate_count' => 0,
'remove_count' => 0,
'remove_with_submissions_count' => 0,
];
foreach ($results as $result) {
if (!is_array($result)) {
continue;
}
$totals['task_count']++;
foreach ([
'target_count',
'existing_count',
'create_count',
'update_count',
'stale_count',
'protected_completed_count',
'protected_certificate_count',
'remove_count',
'remove_with_submissions_count',
] as $key) {
$totals[$key] += (int) ($result[$key] ?? 0);
}
}
if ($apply && $taskId <= 0) {
CronHeartbeat::record('sync_assignments', [
'task_count' => (int) ($totals['task_count'] ?? 0),
'create_count' => (int) ($totals['create_count'] ?? 0),
'remove_count' => (int) ($totals['remove_count'] ?? 0),
]);
}
return [
'job' => 'sync_assignments',
'mode' => $apply ? 'apply' : 'dry_run',
'scope' => 'current_cycle',
'task_id' => $taskId > 0 ? $taskId : null,
'totals' => $totals,
];
}
);
exit($exitCode);