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>
359 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|