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

359 lines
12 KiB
PHP

<?php
declare(strict_types=1);
class CycleCalculatorService
{
public function resolveCurrentCycle(array $task, DateTimeImmutable $now): array
{
$startDate = $this->parseDate((string) ($task['start_date'] ?? ''));
if ($startDate === null) {
return [];
}
$startDate = $startDate->setTime(0, 0, 0);
if ($now < $startDate) {
return [];
}
$frequencyType = (string) ($task['frequency_type'] ?? 'once');
if ($frequencyType !== 'recurring') {
return [
'cycle_key' => 'once',
'cycle_start_at' => $startDate,
'cycle_end_at' => $startDate->setTime(23, 59, 59),
'cycle_boundary_start_at' => $startDate,
'cycle_boundary_end_at' => $startDate->setTime(23, 59, 59),
];
}
$unit = (string) ($task['recurrence_unit'] ?? '');
$interval = max(1, (int) ($task['recurrence_interval'] ?? 1));
if (!in_array($unit, ['day', 'week', 'month', 'year'], true)) {
return [];
}
if ($unit === 'day') {
return $this->resolveDayCycle($startDate, $now, $interval);
}
if ($unit === 'week') {
return $this->resolveWeekCycle($startDate, $now, $interval);
}
if ($unit === 'month') {
return $this->resolveMonthCycle($startDate, $now, $interval);
}
return $this->resolveYearCycle($startDate, $now, $interval);
}
public function resolveInitialCompletionCycle(array $task, DateTimeImmutable $assignedAt): array
{
$startDate = $this->parseDate((string) ($task['start_date'] ?? ''));
if ($startDate === null) {
return [];
}
$startDay = $startDate->setTime(0, 0, 0);
$assignedDay = $assignedAt->setTime(0, 0, 0);
$cycleStart = $assignedDay > $startDay ? $assignedDay : $startDay;
return $this->resolveCompletionCycleFromStart($task, $cycleStart);
}
public function resolveNextCompletionCycle(array $task, array $lastAssignment): array
{
$lastCycleStart = $this->extractDateTime($lastAssignment['cycle_start_at'] ?? null);
if ($lastCycleStart === null) {
return [];
}
$completedAt = $this->extractDateTime($lastAssignment['completed_at'] ?? null);
if ($completedAt === null) {
return [];
}
$unit = (string) ($task['recurrence_unit'] ?? '');
$interval = max(1, (int) ($task['recurrence_interval'] ?? 1));
$plannedNextStart = $this->addInterval($lastCycleStart->setTime(0, 0, 0), $unit, $interval);
if (!($plannedNextStart instanceof DateTimeImmutable)) {
return [];
}
$completionAnchor = $completedAt->setTime(0, 0, 0);
$cycleStart = $plannedNextStart > $completionAnchor ? $plannedNextStart : $completionAnchor;
return $this->resolveCompletionCycleFromStart($task, $cycleStart);
}
public function resolveDueAt(array $task, array $cycle): ?string
{
$cycleStart = $this->extractDateTime($cycle['cycle_start_at'] ?? null);
$cycleEnd = $this->extractDateTime($cycle['cycle_end_at'] ?? null);
if ($cycleStart === null || $cycleEnd === null) {
return null;
}
$ruleType = (string) ($task['deadline_rule_type'] ?? 'cycle_end');
if (!in_array($ruleType, ['cycle_end', 'offset_days', 'weekday', 'monthday'], true)) {
$ruleType = 'cycle_end';
}
$dueAt = null;
if ($ruleType === 'cycle_end') {
$dueAt = $cycleEnd;
} elseif ($ruleType === 'offset_days') {
$offsetDays = (int) ($task['deadline_offset_days'] ?? 0);
if ($offsetDays <= 0) {
return null;
}
$dueAt = $cycleStart
->modify('+' . $offsetDays . ' days')
->setTime(23, 59, 59);
} elseif ($ruleType === 'weekday') {
$weekday = (int) ($task['deadline_weekday'] ?? 0);
if ($weekday < 1 || $weekday > 7) {
return null;
}
$cycleEndDay = $cycleEnd->setTime(0, 0, 0);
$endWeekday = (int) $cycleEndDay->format('N');
$diff = $endWeekday - $weekday;
if ($diff < 0) {
$diff += 7;
}
$dueAt = $cycleEndDay
->modify('-' . $diff . ' days')
->setTime(23, 59, 59);
} elseif ($ruleType === 'monthday') {
$monthDay = (int) ($task['deadline_monthday'] ?? 0);
if ($monthDay < 1 || $monthDay > 31) {
return null;
}
$year = (int) $cycleEnd->format('Y');
$month = (int) $cycleEnd->format('n');
$lastDay = (int) $cycleEnd->format('t');
$resolvedDay = min($monthDay, $lastDay);
$dueAt = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
sprintf('%04d-%02d-%02d 23:59:59', $year, $month, $resolvedDay)
);
if ($dueAt === false) {
return null;
}
}
if (!$dueAt instanceof DateTimeImmutable) {
return null;
}
if ($dueAt < $cycleStart) {
$dueAt = $cycleStart->setTime(23, 59, 59);
}
return $dueAt->format('Y-m-d H:i:s');
}
private function resolveCompletionCycleFromStart(array $task, DateTimeImmutable $cycleStart): array
{
$frequencyType = (string) ($task['frequency_type'] ?? 'once');
if ($frequencyType !== 'recurring') {
return [];
}
$unit = (string) ($task['recurrence_unit'] ?? '');
$interval = max(1, (int) ($task['recurrence_interval'] ?? 1));
if (!in_array($unit, ['day', 'week', 'month', 'year'], true)) {
return [];
}
$normalizedStart = $cycleStart->setTime(0, 0, 0);
$cycleEnd = $this->addInterval($normalizedStart, $unit, $interval);
if (!($cycleEnd instanceof DateTimeImmutable)) {
return [];
}
$cycleEnd = $cycleEnd->modify('-1 second');
return [
'cycle_key' => 'roll-' . $normalizedStart->format('Ymd'),
'cycle_start_at' => $normalizedStart,
'cycle_end_at' => $cycleEnd,
'cycle_boundary_start_at' => $normalizedStart,
'cycle_boundary_end_at' => $cycleEnd,
];
}
private function addInterval(DateTimeImmutable $start, string $unit, int $interval): ?DateTimeImmutable
{
if (!in_array($unit, ['day', 'week', 'month', 'year'], true)) {
return null;
}
if ($interval < 1) {
$interval = 1;
}
if ($unit === 'week') {
return $start->modify('+' . ($interval * 7) . ' days');
}
return $start->modify('+' . $interval . ' ' . $unit . ($interval === 1 ? '' : 's'));
}
private function resolveDayCycle(DateTimeImmutable $startDate, DateTimeImmutable $now, int $interval): array
{
$currentDay = $now->setTime(0, 0, 0);
$daysDiff = max(0, (int) $startDate->diff($currentDay)->format('%a'));
$cycleIndex = (int) floor($daysDiff / $interval);
$boundaryStart = $startDate->modify('+' . ($cycleIndex * $interval) . ' days');
$boundaryEnd = $boundaryStart->modify('+' . $interval . ' days')->modify('-1 second');
return $this->buildCycle(
'day-' . $boundaryStart->format('Ymd'),
$startDate,
$boundaryStart,
$boundaryEnd
);
}
private function resolveWeekCycle(DateTimeImmutable $startDate, DateTimeImmutable $now, int $interval): array
{
$anchorWeekStart = $this->weekStart($startDate);
$currentWeekStart = $this->weekStart($now);
$weeksDiff = max(0, intdiv((int) $anchorWeekStart->diff($currentWeekStart)->format('%a'), 7));
$cycleIndex = (int) floor($weeksDiff / $interval);
$boundaryStart = $anchorWeekStart->modify('+' . ($cycleIndex * $interval * 7) . ' days');
$boundaryEnd = $boundaryStart->modify('+' . ($interval * 7) . ' days')->modify('-1 second');
return $this->buildCycle(
'week-' . $boundaryStart->format('Ymd'),
$startDate,
$boundaryStart,
$boundaryEnd
);
}
private function resolveMonthCycle(DateTimeImmutable $startDate, DateTimeImmutable $now, int $interval): array
{
$anchorMonthIndex = ((int) $startDate->format('Y') * 12) + ((int) $startDate->format('n') - 1);
$currentMonthIndex = ((int) $now->format('Y') * 12) + ((int) $now->format('n') - 1);
$monthDiff = max(0, $currentMonthIndex - $anchorMonthIndex);
$cycleIndex = (int) floor($monthDiff / $interval);
$cycleStartIndex = $anchorMonthIndex + ($cycleIndex * $interval);
$year = (int) floor($cycleStartIndex / 12);
$month = ($cycleStartIndex % 12) + 1;
$boundaryStart = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
sprintf('%04d-%02d-01 00:00:00', $year, $month)
);
if ($boundaryStart === false) {
return [];
}
$boundaryEnd = $boundaryStart->modify('+' . $interval . ' months')->modify('-1 second');
return $this->buildCycle(
'month-' . $boundaryStart->format('Y-m'),
$startDate,
$boundaryStart,
$boundaryEnd
);
}
private function resolveYearCycle(DateTimeImmutable $startDate, DateTimeImmutable $now, int $interval): array
{
$anchorYear = (int) $startDate->format('Y');
$currentYear = (int) $now->format('Y');
$yearDiff = max(0, $currentYear - $anchorYear);
$cycleIndex = (int) floor($yearDiff / $interval);
$cycleYear = $anchorYear + ($cycleIndex * $interval);
$boundaryStart = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
sprintf('%04d-01-01 00:00:00', $cycleYear)
);
if ($boundaryStart === false) {
return [];
}
$boundaryEnd = $boundaryStart->modify('+' . $interval . ' years')->modify('-1 second');
return $this->buildCycle(
'year-' . $boundaryStart->format('Y'),
$startDate,
$boundaryStart,
$boundaryEnd
);
}
private function buildCycle(
string $cycleKey,
DateTimeImmutable $startDate,
DateTimeImmutable $boundaryStart,
DateTimeImmutable $boundaryEnd
): array {
$effectiveStart = $boundaryStart < $startDate ? $startDate : $boundaryStart;
return [
'cycle_key' => $cycleKey,
'cycle_start_at' => $effectiveStart,
'cycle_end_at' => $boundaryEnd,
'cycle_boundary_start_at' => $boundaryStart,
'cycle_boundary_end_at' => $boundaryEnd,
];
}
private function weekStart(DateTimeImmutable $date): DateTimeImmutable
{
$normalized = $date->setTime(0, 0, 0);
$weekday = (int) $normalized->format('N');
$offset = $weekday - 1;
if ($offset <= 0) {
return $normalized;
}
return $normalized->modify('-' . $offset . ' days');
}
private function parseDate(string $value): ?DateTimeImmutable
{
if ($value === '') {
return null;
}
try {
return new DateTimeImmutable($value);
} catch (Throwable $throwable) {
return null;
}
}
private function extractDateTime($value): ?DateTimeImmutable
{
if ($value instanceof DateTimeImmutable) {
return $value;
}
if (!is_string($value) || trim($value) === '') {
return null;
}
try {
return new DateTimeImmutable($value);
} catch (Throwable $throwable) {
return null;
}
}
}