scheduledJobRegistry->definitions(); $nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); foreach ($definitions as $jobKey => $definition) { $existing = $this->scheduledJobRepository->findByKey($jobKey); $job = $this->buildDefaultJob($definition, $jobKey, $nowUtc); if (!$existing) { $this->scheduledJobRepository->create($job); continue; } $normalized = $this->normalizeStoredJob($existing, $definition); if (!$this->jobsDiffer($existing, $normalized)) { continue; } $this->scheduledJobRepository->updateJobMeta((int) $existing['id'], $normalized); } $this->scheduledJobRepository->deleteOrphanedJobs(array_keys($definitions)); } public function isRegistered(string $jobKey): bool { return $this->scheduledJobRegistry->get($jobKey) !== null; } public function listPaged(array $filters): array { $this->ensureSystemJobs(); return $this->scheduledJobRepository->listPaged($filters); } public function find(int $id): ?array { $this->ensureSystemJobs(); return $this->scheduledJobRepository->find($id); } public function updateFromAdmin(int $id, array $input): array { $job = $this->find($id); if (!$job) { return ['ok' => false, 'errors' => [$this->translate('Scheduled job not found')]]; } $definition = $this->scheduledJobRegistry->get((string) ($job['job_key'] ?? '')); if (!$definition) { return ['ok' => false, 'errors' => [$this->translate('Scheduled job is not registered')]]; } $form = [ 'enabled' => isset($input['enabled']) ? 1 : 0, 'timezone' => $this->scheduleCalculator->normalizeTimezone((string) ($input['timezone'] ?? ($job['timezone'] ?? ''))), 'schedule_type' => $this->scheduleCalculator->normalizeScheduleType((string) ($input['schedule_type'] ?? ($job['schedule_type'] ?? 'daily'))), 'schedule_interval' => (int) ($input['schedule_interval'] ?? ($job['schedule_interval'] ?? 1)), 'schedule_time' => $this->scheduleCalculator->normalizeTime((string) ($input['schedule_time'] ?? ($job['schedule_time'] ?? ''))), 'schedule_weekdays_csv' => $this->scheduleCalculator->normalizeWeekdaysCsv( array_key_exists('schedule_weekdays', $input) ? $input['schedule_weekdays'] : ($job['schedule_weekdays_csv'] ?? '') ), 'catchup_once' => isset($input['catchup_once']) ? 1 : 0, ]; $errors = $this->validateForm($form, $definition); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form, 'job' => $job]; } $nextRunAt = null; if ((int) $form['enabled'] === 1) { $nextRun = $this->scheduleCalculator->calculateNextRunUtc($form, new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); $nextRunAt = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null; } $updated = $this->scheduledJobRepository->updateJobMeta((int) $job['id'], [ 'label' => (string) ($definition['label'] ?? (string) ($job['label'] ?? '')), 'description' => (string) ($definition['description'] ?? (string) ($job['description'] ?? '')), 'enabled' => (int) $form['enabled'], 'timezone' => (string) $form['timezone'], 'schedule_type' => (string) $form['schedule_type'], 'schedule_interval' => (int) $form['schedule_interval'], 'schedule_time' => $form['schedule_time'], 'schedule_weekdays_csv' => $form['schedule_weekdays_csv'], 'catchup_once' => (int) $form['catchup_once'], 'next_run_at' => $nextRunAt, ]); if (!$updated) { return ['ok' => false, 'errors' => [$this->translate('Scheduled job could not be saved')], 'form' => $form, 'job' => $job]; } $fresh = $this->find((int) $job['id']); return ['ok' => true, 'job' => $fresh, 'form' => $form]; } public function listRunsByJobId(int $jobId, array $filters): array { return $this->scheduledJobRunRepository->listPagedByJobId($jobId, $filters); } public function purgeRunsExpired(): int { return $this->scheduledJobRunRepository->purgeOlderThanDays(self::RUN_RETENTION_DAYS); } public function scheduleSummary(array $job): string { $type = $this->scheduleCalculator->normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily')); $interval = (int) ($job['schedule_interval'] ?? 1); $time = (string) ($job['schedule_time'] ?? ''); if ($type === 'hourly') { return sprintf($this->translate('Every %d hour(s)'), max(1, $interval)); } if ($type === 'daily') { return sprintf($this->translate('Every %d day(s) at %s'), max(1, $interval), $time !== '' ? $time : '--:--'); } $weekdays = $this->scheduleCalculator->parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? '')); $labels = []; foreach ($weekdays as $day) { $labels[] = $this->weekdayLabel($day); } $weekdayText = $labels ? implode(', ', $labels) : $this->translate('No weekdays'); return sprintf( $this->translate('Every %d week(s) on %s at %s'), max(1, $interval), $weekdayText, $time !== '' ? $time : '--:--' ); } public function weekdaysFromCsv(?string $csv): array { return $this->scheduleCalculator->parseWeekdays((string) ($csv ?? '')); } private function validateForm(array $form, array $definition): array { $errors = []; $allowedTypes = $definition['allowed_schedule_types'] ?? ['hourly', 'daily', 'weekly']; $type = (string) ($form['schedule_type'] ?? 'daily'); if (!in_array($type, $allowedTypes, true)) { $errors[] = $this->translate('Schedule type is invalid'); return $errors; } $interval = (int) ($form['schedule_interval'] ?? 0); if ($type === 'hourly' && ($interval < 1 || $interval > 24)) { $errors[] = $this->translate('Hourly interval must be between 1 and 24'); } elseif ($type === 'daily' && ($interval < 1 || $interval > 365)) { $errors[] = $this->translate('Daily interval must be between 1 and 365'); } elseif ($type === 'weekly' && ($interval < 1 || $interval > 52)) { $errors[] = $this->translate('Weekly interval must be between 1 and 52'); } $timezone = trim((string) ($form['timezone'] ?? '')); try { new \DateTimeZone($timezone); } catch (\Throwable $exception) { $errors[] = $this->translate('Timezone is invalid'); } if (in_array($type, ['daily', 'weekly'], true) && $form['schedule_time'] === null) { $errors[] = $this->translate('Schedule time is required'); } if ($type === 'weekly') { $weekdays = $this->scheduleCalculator->parseWeekdays((string) ($form['schedule_weekdays_csv'] ?? '')); if (!$weekdays) { $errors[] = $this->translate('At least one weekday is required for weekly schedule'); } } return $errors; } private function buildDefaultJob(array $definition, string $jobKey, \DateTimeImmutable $nowUtc): array { $type = (string) ($definition['default_schedule_type'] ?? 'daily'); $job = [ 'job_key' => $jobKey, 'label' => (string) ($definition['label'] ?? $jobKey), 'description' => (string) ($definition['description'] ?? ''), 'enabled' => (int) ($definition['default_enabled'] ?? 1), 'timezone' => $this->scheduleCalculator->normalizeTimezone((string) ($definition['default_timezone'] ?? '')), 'schedule_type' => $this->scheduleCalculator->normalizeScheduleType($type), 'schedule_interval' => $this->scheduleCalculator->normalizeInterval($type, (int) ($definition['default_schedule_interval'] ?? 1)), 'schedule_time' => $this->scheduleCalculator->normalizeTime((string) ($definition['default_schedule_time'] ?? '')), 'schedule_weekdays_csv' => $this->scheduleCalculator->normalizeWeekdaysCsv($definition['default_schedule_weekdays_csv'] ?? null), 'catchup_once' => (int) ($definition['default_catchup_once'] ?? 1), 'next_run_at' => null, ]; if ((int) $job['enabled'] === 1) { $nextRun = $this->scheduleCalculator->calculateNextRunUtc($job, $nowUtc); $job['next_run_at'] = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null; } return $job; } private function normalizeStoredJob(array $existing, array $definition): array { $type = $this->scheduleCalculator->normalizeScheduleType((string) ($existing['schedule_type'] ?? ($definition['default_schedule_type'] ?? 'daily'))); $normalized = [ // label and description always come from the registry — admin edits to those fields are not preserved. 'label' => (string) ($definition['label'] ?? (string) ($existing['label'] ?? '')), 'description' => (string) ($definition['description'] ?? (string) ($existing['description'] ?? '')), 'enabled' => (int) ($existing['enabled'] ?? ($definition['default_enabled'] ?? 1)), 'timezone' => $this->scheduleCalculator->normalizeTimezone((string) ($existing['timezone'] ?? ($definition['default_timezone'] ?? ''))), 'schedule_type' => $type, 'schedule_interval' => $this->scheduleCalculator->normalizeInterval($type, (int) ($existing['schedule_interval'] ?? ($definition['default_schedule_interval'] ?? 1))), 'schedule_time' => $this->scheduleCalculator->normalizeTime((string) ($existing['schedule_time'] ?? ($definition['default_schedule_time'] ?? ''))), 'schedule_weekdays_csv' => $this->scheduleCalculator->normalizeWeekdaysCsv( (string) ($existing['schedule_weekdays_csv'] ?? ($definition['default_schedule_weekdays_csv'] ?? '')) ), 'catchup_once' => (int) ($existing['catchup_once'] ?? ($definition['default_catchup_once'] ?? 1)), 'next_run_at' => (string) ($existing['next_run_at'] ?? ''), ]; if ($normalized['next_run_at'] === '' && (int) $normalized['enabled'] === 1) { $nextRun = $this->scheduleCalculator->calculateNextRunUtc($normalized, new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); $normalized['next_run_at'] = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null; } else { $normalized['next_run_at'] = $normalized['next_run_at'] !== '' ? $normalized['next_run_at'] : null; } return $normalized; } private function jobsDiffer(array $existing, array $normalized): bool { $keys = [ 'label', 'description', 'enabled', 'timezone', 'schedule_type', 'schedule_interval', 'schedule_time', 'schedule_weekdays_csv', 'catchup_once', 'next_run_at', ]; foreach ($keys as $key) { $a = (string) ($existing[$key] ?? ''); $b = (string) ($normalized[$key] ?? ''); if ($a !== $b) { return true; } } return false; } private function weekdayLabel(int $weekday): string { return match ($weekday) { 1 => $this->translate('Mon'), 2 => $this->translate('Tue'), 3 => $this->translate('Wed'), 4 => $this->translate('Thu'), 5 => $this->translate('Fri'), 6 => $this->translate('Sat'), 7 => $this->translate('Sun'), default => '-', }; } }