diff --git a/modules/fx-rates/bootstrap.php b/modules/fx-rates/bootstrap.php
index e7b9672..a16e237 100644
--- a/modules/fx-rates/bootstrap.php
+++ b/modules/fx-rates/bootstrap.php
@@ -68,8 +68,6 @@ $mm->registerFunction($moduleName, 'settings', static function (): array {
'preferred_currencies' => $preferredCurrencies,
'currency_catalog' => $currencyCatalog,
'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')),
- 'daily_refresh_enabled' => array_key_exists('daily_refresh_enabled', $saved) ? (bool) $saved['daily_refresh_enabled'] : true,
- 'daily_refresh_hour' => max(0, min(23, (int) ($saved['daily_refresh_hour'] ?? 18))),
'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? 'Europe/Berlin')) ?: 'Europe/Berlin',
];
});
diff --git a/modules/fx-rates/module.json b/modules/fx-rates/module.json
index 508e36b..29319f2 100644
--- a/modules/fx-rates/module.json
+++ b/modules/fx-rates/module.json
@@ -22,20 +22,24 @@
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
{ "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "select", "required": false, "help": "Basis fuer die Anzeige der zuletzt gespeicherten Kurse im Modul." },
{ "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "multiselect", "required": false, "help": "Auswahl aus dem synchronisierten Waehrungskatalog." },
- { "name": "daily_refresh_enabled", "label": "Taeglichen Abruf aktivieren", "type": "checkbox", "required": false },
- { "name": "daily_refresh_hour", "label": "Taegliche Abrufstunde", "type": "number", "required": false, "help": "Lokale Stunde fuer den Scheduler, standardmaessig 18." },
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
]
},
- "interval_tasks": [
+ "cron_tasks": [
{
- "name": "daily_refresh",
- "label": "Taeglicher FX-Abruf",
+ "name": "rates_refresh",
+ "label": "Kursabruf",
"callback": "scheduled_refresh",
- "enabled_setting": "daily_refresh_enabled",
"default_enabled": true,
- "default_interval_hours": 24,
- "lock_minutes": 120
+ "default_cron": "0 18 * * *",
+ "default_timezone": "Europe/Berlin",
+ "timezone_setting": "schedule_timezone",
+ "lock_minutes": 120,
+ "help": "Zeitgesteuerter Abruf und das Speichern neuer FX-Snapshots.",
+ "builder": {
+ "allow_manual": true,
+ "presets": ["daily", "every_x_days", "weekly", "monthly_day", "every_x_hours"]
+ }
}
],
"db_defaults": {
diff --git a/modules/fx-rates/src/Domain/FxRatesService.php b/modules/fx-rates/src/Domain/FxRatesService.php
index 22cbd26..1dcbe1f 100644
--- a/modules/fx-rates/src/Domain/FxRatesService.php
+++ b/modules/fx-rates/src/Domain/FxRatesService.php
@@ -302,42 +302,10 @@ final class FxRatesService
public function runScheduledRefresh(array $context = []): array
{
- if (!$this->dailyRefreshEnabled()) {
- return ['ok' => true, 'message' => 'Taeglicher FX-Abruf ist deaktiviert.', 'skipped' => true];
- }
-
- $timezone = $this->scheduleTimezone();
- $nowLocal = new DateTimeImmutable('now', $timezone);
- $targetHour = $this->dailyRefreshHour();
-
- if ((int) $nowLocal->format('G') !== $targetHour) {
- return [
- 'ok' => true,
- 'message' => 'Kein FX-Abruf: aktuelles Zeitfenster ist ' . $nowLocal->format('H:i') . ', Ziel ist ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
- 'skipped' => true,
- 'context' => $context,
- ];
- }
-
- $latest = $this->repository->getLatestFetch($this->defaultBaseCurrency());
- if (is_array($latest) && trim((string) ($latest['fetched_at'] ?? '')) !== '') {
- $latestLocal = new DateTimeImmutable((string) $latest['fetched_at'], new DateTimeZone('UTC'));
- $latestLocal = $latestLocal->setTimezone($timezone);
- if ($latestLocal->format('Y-m-d') === $nowLocal->format('Y-m-d') && (int) $latestLocal->format('G') >= $targetHour) {
- return [
- 'ok' => true,
- 'message' => 'Kein FX-Abruf: fuer heute existiert bereits ein Snapshot nach ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
- 'skipped' => true,
- 'fetch' => $this->localizeFetch($latest),
- 'context' => $context,
- ];
- }
- }
-
$result = $this->refreshLatestRates(null, $this->defaultBaseCurrency());
return [
'ok' => true,
- 'message' => 'Taeglicher FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.',
+ 'message' => 'Geplanter FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.',
'result' => $result,
'context' => $context,
];
@@ -980,16 +948,6 @@ final class FxRatesService
return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
}
- private function dailyRefreshEnabled(): bool
- {
- return !empty($this->settings['daily_refresh_enabled']);
- }
-
- private function dailyRefreshHour(): int
- {
- return max(0, min(23, (int) ($this->settings['daily_refresh_hour'] ?? 18)));
- }
-
private function scheduleTimezone(): DateTimeZone
{
$timezone = trim((string) ($this->settings['schedule_timezone'] ?? 'Europe/Berlin'));
diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php
index 85f61d5..89cf619 100644
--- a/partials/landingpages/modules/setup.php
+++ b/partials/landingpages/modules/setup.php
@@ -43,6 +43,8 @@ foreach ($fields as $field) {
$isFxRatesSetup = $moduleName === 'fx-rates';
$current = modules()->settings($moduleName);
$intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName);
+$cronTaskDefinitions = modules()->cronTasks($moduleName);
+$cronTaskStatuses = modules()->cronTaskStatuses($moduleName);
$setupActions = modules()->hasFunction($moduleName, 'setup_actions')
? (array) module_fn($moduleName, 'setup_actions')
: [];
@@ -285,6 +287,16 @@ $formatRunTimestamp = static function (?string $value): string {
return date('Y-m-d H:i:s', $ts);
};
+$cronWeekdays = [
+ '0' => 'Sonntag',
+ '1' => 'Montag',
+ '2' => 'Dienstag',
+ '3' => 'Mittwoch',
+ '4' => 'Donnerstag',
+ '5' => 'Freitag',
+ '6' => 'Samstag',
+];
+
$renderField = function (array $field) use (&$current, $getNested, $driverOptions): void {
$name = (string)($field['name'] ?? '');
if ($name === '') {
@@ -371,6 +383,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$current = array_replace_recursive($current, $payload);
+ if ($cronTaskDefinitions !== []) {
+ $cronJobs = is_array($current['cron_jobs'] ?? null) ? $current['cron_jobs'] : [];
+ foreach ($cronTaskDefinitions as $cronTask) {
+ if (!is_array($cronTask)) {
+ continue;
+ }
+ $cronName = trim((string) ($cronTask['name'] ?? ''));
+ if ($cronName === '') {
+ continue;
+ }
+
+ $prefix = 'cron_job_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $cronName);
+ $cronJobs[$cronName] = array_merge(
+ is_array($cronJobs[$cronName] ?? null) ? $cronJobs[$cronName] : [],
+ [
+ 'enabled' => isset($_POST[$prefix . '_enabled']),
+ 'cron_expression' => trim((string) ($_POST[$prefix . '_cron_expression'] ?? ($cronTask['default_cron'] ?? ''))),
+ 'timezone' => trim((string) ($_POST[$prefix . '_timezone'] ?? ($cronTask['default_timezone'] ?? 'UTC'))),
+ 'builder' => [
+ 'mode' => trim((string) ($_POST[$prefix . '_builder_mode'] ?? 'builder')),
+ 'kind' => trim((string) ($_POST[$prefix . '_builder_kind'] ?? 'daily')),
+ 'time' => trim((string) ($_POST[$prefix . '_builder_time'] ?? '18:00')),
+ 'interval_days' => max(1, (int) ($_POST[$prefix . '_builder_interval_days'] ?? 2)),
+ 'weekday' => trim((string) ($_POST[$prefix . '_builder_weekday'] ?? '1')),
+ 'month_day' => max(1, min(31, (int) ($_POST[$prefix . '_builder_month_day'] ?? 1))),
+ 'interval_hours' => max(1, (int) ($_POST[$prefix . '_builder_interval_hours'] ?? 6)),
+ ],
+ ]
+ );
+ }
+ $current['cron_jobs'] = $cronJobs;
+ }
+
$postedTestGroup = (string)($_POST['test_db'] ?? '');
$postedResetGroup = (string)($_POST['reset_db'] ?? '');
$postedSetupAction = trim((string)($_POST['module_setup_action'] ?? ''));
@@ -611,21 +656,14 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
Scheduler
-
Taeglicher Abruf
+ Cron-Zeitzone
-
-
@@ -736,6 +774,68 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
+
+
+
+
+
Automationen
+
Cron-Jobs
+
Diese Jobs werden ueber den zentralen Nexus-Scheduler ausgefuehrt. Der System-Cron sollte den CLI-Runner jede Minute starten.
+
+
+
+
+
+
Zugriff verwalten
@@ -1034,6 +1134,68 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
+
+
+
+
+
Automationen
+
Cron-Jobs
+
Diese Jobs werden ueber den zentralen Nexus-Scheduler ausgefuehrt. Der System-Cron sollte den CLI-Runner jede Minute starten.
+
+
+
+
+
+
@@ -1172,5 +1334,79 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
Zurück
+
+
diff --git a/public/index.php b/public/index.php
index db98d62..235252b 100755
--- a/public/index.php
+++ b/public/index.php
@@ -174,6 +174,7 @@ if (preg_match('~^api/fx-rates(?:/(.*))?$~', $uriPath, $apiMatches)) {
require_once $projectRoot . '/modules/fx-rates/bootstrap.php';
app()->modules()->runDueIntervalTasks('fx-rates');
+ app()->modules()->runDueCronTasks('fx-rates');
try {
$service = module_fn('fx-rates', 'service');
@@ -261,6 +262,7 @@ if (str_starts_with($uriPath, 'modules/install')) {
}
if ($modulePage) {
app()->modules()->runDueIntervalTasks($module);
+ app()->modules()->runDueCronTasks($module);
$target = $modulePage;
} else {
http_response_code(404);
diff --git a/src/App/BaseSchema.php b/src/App/BaseSchema.php
index e47b0af..536ea39 100644
--- a/src/App/BaseSchema.php
+++ b/src/App/BaseSchema.php
@@ -76,6 +76,22 @@ final class BaseSchema
)"
);
+ $pdo->exec(
+ "CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
+ module_name TEXT NOT NULL,
+ job_name TEXT NOT NULL,
+ last_scheduled_for TIMESTAMPTZ NULL,
+ last_started_at TIMESTAMPTZ NULL,
+ last_finished_at TIMESTAMPTZ NULL,
+ last_success_at TIMESTAMPTZ NULL,
+ last_status TEXT NULL,
+ last_message TEXT NULL,
+ lock_until TIMESTAMPTZ NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (module_name, job_name)
+ )"
+ );
+
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub TEXT PRIMARY KEY,
@@ -179,6 +195,22 @@ final class BaseSchema
)"
);
+ $pdo->exec(
+ "CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
+ module_name TEXT NOT NULL,
+ job_name TEXT NOT NULL,
+ last_scheduled_for TEXT NULL,
+ last_started_at TEXT NULL,
+ last_finished_at TEXT NULL,
+ last_success_at TEXT NULL,
+ last_status TEXT NULL,
+ last_message TEXT NULL,
+ lock_until TEXT NULL,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY (module_name, job_name)
+ )"
+ );
+
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub TEXT PRIMARY KEY,
@@ -282,6 +314,22 @@ final class BaseSchema
)"
);
+ $pdo->exec(
+ "CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
+ module_name VARCHAR(190) NOT NULL,
+ job_name VARCHAR(190) NOT NULL,
+ last_scheduled_for DATETIME NULL,
+ last_started_at DATETIME NULL,
+ last_finished_at DATETIME NULL,
+ last_success_at DATETIME NULL,
+ last_status VARCHAR(32) NULL,
+ last_message TEXT NULL,
+ lock_until DATETIME NULL,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (module_name, job_name)
+ )"
+ );
+
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub VARCHAR(190) PRIMARY KEY,
diff --git a/src/App/CronExpression.php b/src/App/CronExpression.php
new file mode 100644
index 0000000..09a3599
--- /dev/null
+++ b/src/App/CronExpression.php
@@ -0,0 +1,214 @@
+ */
+ private array $minutes;
+ /** @var array */
+ private array $hours;
+ /** @var array */
+ private array $daysOfMonth;
+ /** @var array */
+ private array $months;
+ /** @var array */
+ private array $daysOfWeek;
+
+ private function __construct(
+ private string $expression,
+ array $minutes,
+ array $hours,
+ array $daysOfMonth,
+ array $months,
+ array $daysOfWeek,
+ private bool $daysOfMonthWildcard,
+ private bool $daysOfWeekWildcard
+ ) {
+ $this->minutes = $minutes;
+ $this->hours = $hours;
+ $this->daysOfMonth = $daysOfMonth;
+ $this->months = $months;
+ $this->daysOfWeek = $daysOfWeek;
+ }
+
+ public static function parse(string $expression): self
+ {
+ $normalized = preg_replace('/\s+/', ' ', trim($expression)) ?? '';
+ if ($normalized === '') {
+ throw new \InvalidArgumentException('Cron-Ausdruck fehlt.');
+ }
+
+ $parts = explode(' ', $normalized);
+ if (count($parts) !== 5) {
+ throw new \InvalidArgumentException('Cron-Ausdruck muss aus 5 Feldern bestehen.');
+ }
+
+ return new self(
+ $normalized,
+ self::parseField($parts[0], 0, 59),
+ self::parseField($parts[1], 0, 23),
+ self::parseField($parts[2], 1, 31),
+ self::parseField($parts[3], 1, 12, [
+ 'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4,
+ 'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8,
+ 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12,
+ ]),
+ self::parseField($parts[4], 0, 6, [
+ 'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3,
+ 'THU' => 4, 'FRI' => 5, 'SAT' => 6,
+ '7' => 0,
+ ]),
+ trim($parts[2]) === '*',
+ trim($parts[4]) === '*'
+ );
+ }
+
+ public function expression(): string
+ {
+ return $this->expression;
+ }
+
+ public function matches(DateTimeImmutable $utcDateTime, DateTimeZone $timezone): bool
+ {
+ $local = $utcDateTime->setTimezone($timezone);
+ $minute = (int) $local->format('i');
+ $hour = (int) $local->format('G');
+ $dayOfMonth = (int) $local->format('j');
+ $month = (int) $local->format('n');
+ $dayOfWeek = (int) $local->format('w');
+
+ if (!isset($this->minutes[$minute]) || !isset($this->hours[$hour]) || !isset($this->months[$month])) {
+ return false;
+ }
+
+ $dayOfMonthMatch = isset($this->daysOfMonth[$dayOfMonth]);
+ $dayOfWeekMatch = isset($this->daysOfWeek[$dayOfWeek]);
+ if ($this->daysOfMonthWildcard && $this->daysOfWeekWildcard) {
+ $dayMatches = true;
+ } elseif ($this->daysOfMonthWildcard) {
+ $dayMatches = $dayOfWeekMatch;
+ } elseif ($this->daysOfWeekWildcard) {
+ $dayMatches = $dayOfMonthMatch;
+ } else {
+ $dayMatches = $dayOfMonthMatch || $dayOfWeekMatch;
+ }
+
+ return $dayMatches;
+ }
+
+ public function previousRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookbackMinutes = 527040): ?DateTimeImmutable
+ {
+ $cursor = $this->floorToMinute($utcDateTime);
+ for ($i = 0; $i <= $lookbackMinutes; $i++) {
+ if ($this->matches($cursor, $timezone)) {
+ return $cursor;
+ }
+ $cursor = $cursor->sub(new DateInterval('PT1M'));
+ }
+
+ return null;
+ }
+
+ public function nextRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookaheadMinutes = 527040): ?DateTimeImmutable
+ {
+ $cursor = $this->floorToMinute($utcDateTime)->add(new DateInterval('PT1M'));
+ for ($i = 0; $i <= $lookaheadMinutes; $i++) {
+ if ($this->matches($cursor, $timezone)) {
+ return $cursor;
+ }
+ $cursor = $cursor->add(new DateInterval('PT1M'));
+ }
+
+ return null;
+ }
+
+ /** @return array */
+ private static function parseField(string $field, int $min, int $max, array $aliases = []): array
+ {
+ $field = strtoupper(trim($field));
+ if ($field === '*') {
+ $all = [];
+ for ($value = $min; $value <= $max; $value++) {
+ $all[$value] = true;
+ }
+ return $all;
+ }
+
+ $values = [];
+ foreach (explode(',', $field) as $segment) {
+ $segment = strtoupper(trim($segment));
+ if ($segment === '') {
+ continue;
+ }
+
+ $step = 1;
+ if (str_contains($segment, '/')) {
+ [$segment, $stepPart] = explode('/', $segment, 2);
+ if (!is_numeric($stepPart) || (int) $stepPart <= 0) {
+ throw new \InvalidArgumentException('Ungueltiger Cron-Step in Feld "' . $field . '".');
+ }
+ $step = (int) $stepPart;
+ }
+
+ if ($segment === '*') {
+ $start = $min;
+ $end = $max;
+ } elseif (str_contains($segment, '-')) {
+ [$startPart, $endPart] = explode('-', $segment, 2);
+ $start = self::normalizePart($startPart, $aliases);
+ $end = self::normalizePart($endPart, $aliases);
+ } else {
+ $start = self::normalizePart($segment, $aliases);
+ $end = $start;
+ }
+
+ if ($start < $min || $start > $max || $end < $min || $end > $max || $end < $start) {
+ throw new \InvalidArgumentException('Cron-Feld "' . $field . '" liegt ausserhalb des erlaubten Bereichs.');
+ }
+
+ for ($value = $start; $value <= $end; $value += $step) {
+ $values[$value] = true;
+ }
+ }
+
+ if ($values === []) {
+ throw new \InvalidArgumentException('Cron-Feld "' . $field . '" ist leer.');
+ }
+
+ ksort($values);
+ return $values;
+ }
+
+ private static function normalizePart(string $part, array $aliases): int
+ {
+ $part = strtoupper(trim($part));
+ if ($part === '') {
+ throw new \InvalidArgumentException('Leerer Cron-Wert.');
+ }
+
+ if (array_key_exists($part, $aliases)) {
+ return (int) $aliases[$part];
+ }
+
+ if (!is_numeric($part)) {
+ throw new \InvalidArgumentException('Ungueltiger Cron-Wert "' . $part . '".');
+ }
+
+ return (int) $part;
+ }
+
+ private function floorToMinute(DateTimeImmutable $utcDateTime): DateTimeImmutable
+ {
+ return $utcDateTime->setTime(
+ (int) $utcDateTime->format('H'),
+ (int) $utcDateTime->format('i'),
+ 0
+ );
+ }
+}
diff --git a/src/App/ModuleCronScheduler.php b/src/App/ModuleCronScheduler.php
new file mode 100644
index 0000000..fd82a3d
--- /dev/null
+++ b/src/App/ModuleCronScheduler.php
@@ -0,0 +1,371 @@
+modules->get($moduleName);
+ $tasks = is_array($module['cron_tasks'] ?? null) ? $module['cron_tasks'] : [];
+ $definitions = [];
+
+ foreach ($tasks as $task) {
+ if (!is_array($task)) {
+ continue;
+ }
+
+ $name = trim((string) ($task['name'] ?? ''));
+ $callback = trim((string) ($task['callback'] ?? ''));
+ if ($name === '' || $callback === '') {
+ continue;
+ }
+
+ $definitions[$name] = [
+ 'name' => $name,
+ 'label' => trim((string) ($task['label'] ?? $name)),
+ 'callback' => $callback,
+ 'default_enabled' => array_key_exists('default_enabled', $task) ? (bool) $task['default_enabled'] : false,
+ 'default_cron' => trim((string) ($task['default_cron'] ?? '0 * * * *')),
+ 'default_timezone' => trim((string) ($task['default_timezone'] ?? 'UTC')) ?: 'UTC',
+ 'timezone_setting' => trim((string) ($task['timezone_setting'] ?? '')),
+ 'lock_minutes' => max(1, (int) ($task['lock_minutes'] ?? 10)),
+ 'builder' => is_array($task['builder'] ?? null) ? $task['builder'] : [],
+ 'help' => trim((string) ($task['help'] ?? '')),
+ ];
+ }
+
+ return $definitions;
+ }
+
+ public function statuses(string $moduleName): array
+ {
+ $definitions = $this->definitions($moduleName);
+ if ($definitions === []) {
+ return [];
+ }
+
+ $settings = $this->modules->settings($moduleName);
+ $states = $this->fetchStates($moduleName);
+ $nowUtc = new DateTimeImmutable('now', new DateTimeZone('UTC'));
+ $statuses = [];
+
+ foreach ($definitions as $name => $definition) {
+ $state = $states[$name] ?? [];
+ $config = $this->jobConfig($definition, $settings);
+ $timezone = $this->safeTimezone((string) ($config['timezone'] ?? 'UTC'));
+ $expression = trim((string) ($config['cron_expression'] ?? ''));
+ $isLocked = $this->isLockActive($state, $nowUtc->getTimestamp());
+ $parseError = null;
+ $previousDueUtc = null;
+ $nextDueUtc = null;
+ $isDue = false;
+
+ try {
+ $cron = CronExpression::parse($expression);
+ $previousDueUtc = $cron->previousRun($nowUtc, $timezone);
+ $lastScheduledFor = $this->parseUtc((string) ($state['last_scheduled_for'] ?? ''));
+ $isDue = !empty($config['enabled'])
+ && !$isLocked
+ && $previousDueUtc instanceof DateTimeImmutable
+ && ($lastScheduledFor === null || $previousDueUtc > $lastScheduledFor);
+ $nextDueUtc = $isDue
+ ? $previousDueUtc
+ : $cron->nextRun($nowUtc, $timezone);
+ } catch (\Throwable $exception) {
+ $parseError = $exception->getMessage();
+ }
+
+ $statuses[] = $definition + [
+ 'config' => $config,
+ 'state' => $state,
+ 'enabled' => !empty($config['enabled']),
+ 'cron_expression' => $expression,
+ 'timezone' => $timezone->getName(),
+ 'is_due' => $isDue,
+ 'is_locked' => $isLocked,
+ 'parse_error' => $parseError,
+ 'previous_due_at' => $previousDueUtc?->format('Y-m-d H:i:s'),
+ 'next_due_at' => $nextDueUtc?->format('Y-m-d H:i:s'),
+ 'previous_due_at_local' => $previousDueUtc?->setTimezone($timezone)->format('Y-m-d H:i:s'),
+ 'next_due_at_local' => $nextDueUtc?->setTimezone($timezone)->format('Y-m-d H:i:s'),
+ ];
+ }
+
+ return $statuses;
+ }
+
+ public function runDue(string $moduleName): array
+ {
+ $results = [];
+ foreach ($this->statuses($moduleName) as $task) {
+ if (empty($task['enabled']) || empty($task['is_due']) || !empty($task['parse_error'])) {
+ continue;
+ }
+
+ if (!$this->modules->hasFunction($moduleName, (string) $task['callback'])) {
+ $results[] = [
+ 'task' => $task['name'],
+ 'ok' => false,
+ 'message' => 'Callback nicht registriert.',
+ ];
+ continue;
+ }
+
+ if (!$this->acquireLock($moduleName, (string) $task['name'], (int) $task['lock_minutes'])) {
+ continue;
+ }
+
+ $startedAt = gmdate('Y-m-d H:i:s');
+ $scheduledForUtc = trim((string) ($task['previous_due_at'] ?? ''));
+ $timezone = $this->safeTimezone((string) ($task['timezone'] ?? 'UTC'));
+
+ $this->persistState($moduleName, (string) $task['name'], [
+ 'last_started_at' => $startedAt,
+ 'last_status' => 'running',
+ 'last_message' => 'Cron-Lauf gestartet.',
+ ]);
+
+ try {
+ $result = $this->modules->call($moduleName, (string) $task['callback'], [
+ 'task' => $task,
+ 'trigger' => 'cron_runner',
+ 'started_at' => $startedAt,
+ 'scheduled_for_utc' => $scheduledForUtc,
+ 'scheduled_for_local' => $scheduledForUtc !== ''
+ ? $this->parseUtc($scheduledForUtc)?->setTimezone($timezone)?->format('Y-m-d H:i:s')
+ : null,
+ 'timezone' => $timezone->getName(),
+ ]);
+
+ $ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
+ $skipped = is_array($result) && !empty($result['skipped']);
+ $message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
+ $finishedAt = gmdate('Y-m-d H:i:s');
+
+ $payload = [
+ 'last_finished_at' => $finishedAt,
+ 'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
+ 'last_message' => $message,
+ 'lock_until' => null,
+ 'last_scheduled_for' => $scheduledForUtc !== '' ? $scheduledForUtc : null,
+ ];
+ if ($ok && !$skipped) {
+ $payload['last_success_at'] = $finishedAt;
+ }
+ $this->persistState($moduleName, (string) $task['name'], $payload);
+
+ $results[] = [
+ 'task' => $task['name'],
+ 'ok' => $ok,
+ 'message' => $message,
+ ];
+ } catch (\Throwable $e) {
+ $finishedAt = gmdate('Y-m-d H:i:s');
+ $this->persistState($moduleName, (string) $task['name'], [
+ 'last_finished_at' => $finishedAt,
+ 'last_status' => 'error',
+ 'last_message' => $e->getMessage(),
+ 'lock_until' => null,
+ 'last_scheduled_for' => $scheduledForUtc !== '' ? $scheduledForUtc : null,
+ ]);
+ $results[] = [
+ 'task' => $task['name'],
+ 'ok' => false,
+ 'message' => $e->getMessage(),
+ ];
+ }
+ }
+
+ return $results;
+ }
+
+ private function jobConfig(array $definition, array $settings): array
+ {
+ $jobs = is_array($settings['cron_jobs'] ?? null) ? $settings['cron_jobs'] : [];
+ $job = is_array($jobs[$definition['name']] ?? null) ? $jobs[$definition['name']] : [];
+ $timezoneSetting = trim((string) ($definition['timezone_setting'] ?? ''));
+ $fallbackTimezone = $timezoneSetting !== '' ? trim((string) ($settings[$timezoneSetting] ?? '')) : '';
+
+ return [
+ 'enabled' => array_key_exists('enabled', $job) ? $this->settingBool($job['enabled'], (bool) $definition['default_enabled']) : (bool) $definition['default_enabled'],
+ 'cron_expression' => trim((string) ($job['cron_expression'] ?? $definition['default_cron'] ?? '')),
+ 'timezone' => trim((string) ($job['timezone'] ?? ($fallbackTimezone !== '' ? $fallbackTimezone : $definition['default_timezone']))),
+ 'builder' => is_array($job['builder'] ?? null) ? $job['builder'] : [],
+ ];
+ }
+
+ private function fetchStates(string $moduleName): array
+ {
+ if (!$this->pdo) {
+ return [];
+ }
+
+ $stmt = $this->pdo->prepare(
+ 'SELECT *
+ FROM nexus_module_cron_runs
+ WHERE module_name = :module_name'
+ );
+ $stmt->execute(['module_name' => $moduleName]);
+
+ $states = [];
+ foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
+ $name = trim((string) ($row['job_name'] ?? ''));
+ if ($name !== '') {
+ $states[$name] = $row;
+ }
+ }
+
+ return $states;
+ }
+
+ private function acquireLock(string $moduleName, string $jobName, int $lockMinutes): bool
+ {
+ if (!$this->pdo) {
+ return true;
+ }
+
+ $states = $this->fetchStates($moduleName);
+ $state = $states[$jobName] ?? [];
+ if ($this->isLockActive($state, time())) {
+ return false;
+ }
+
+ $lockUntil = gmdate('Y-m-d H:i:s', time() + ($lockMinutes * 60));
+ $this->persistState($moduleName, $jobName, [
+ 'lock_until' => $lockUntil,
+ ]);
+
+ return true;
+ }
+
+ private function persistState(string $moduleName, string $jobName, array $values): void
+ {
+ if (!$this->pdo) {
+ return;
+ }
+
+ $current = $this->fetchStates($moduleName)[$jobName] ?? [];
+ $payload = array_merge($current, $values, [
+ 'module_name' => $moduleName,
+ 'job_name' => $jobName,
+ 'updated_at' => gmdate('Y-m-d H:i:s'),
+ ]);
+
+ $params = [
+ 'module_name' => $moduleName,
+ 'job_name' => $jobName,
+ 'last_scheduled_for' => $this->nullOrString($payload['last_scheduled_for'] ?? null),
+ 'last_started_at' => $this->nullOrString($payload['last_started_at'] ?? null),
+ 'last_finished_at' => $this->nullOrString($payload['last_finished_at'] ?? null),
+ 'last_success_at' => $this->nullOrString($payload['last_success_at'] ?? null),
+ 'last_status' => $this->nullOrString($payload['last_status'] ?? null),
+ 'last_message' => $this->nullOrString($payload['last_message'] ?? null),
+ 'lock_until' => $this->nullOrString($payload['lock_until'] ?? null),
+ 'updated_at' => $payload['updated_at'],
+ ];
+
+ $updateStmt = $this->pdo->prepare(
+ 'UPDATE nexus_module_cron_runs
+ SET last_scheduled_for = :last_scheduled_for,
+ last_started_at = :last_started_at,
+ last_finished_at = :last_finished_at,
+ last_success_at = :last_success_at,
+ last_status = :last_status,
+ last_message = :last_message,
+ lock_until = :lock_until,
+ updated_at = :updated_at
+ WHERE module_name = :module_name
+ AND job_name = :job_name'
+ );
+ $updateStmt->execute($params);
+ if ($updateStmt->rowCount() > 0) {
+ return;
+ }
+
+ $insertStmt = $this->pdo->prepare(
+ 'INSERT INTO nexus_module_cron_runs (
+ module_name,
+ job_name,
+ last_scheduled_for,
+ last_started_at,
+ last_finished_at,
+ last_success_at,
+ last_status,
+ last_message,
+ lock_until,
+ updated_at
+ ) VALUES (
+ :module_name,
+ :job_name,
+ :last_scheduled_for,
+ :last_started_at,
+ :last_finished_at,
+ :last_success_at,
+ :last_status,
+ :last_message,
+ :lock_until,
+ :updated_at
+ )'
+ );
+ $insertStmt->execute($params);
+ }
+
+ private function nullOrString(mixed $value): ?string
+ {
+ $value = trim((string) $value);
+ return $value !== '' ? $value : null;
+ }
+
+ private function settingBool(mixed $value, bool $default): bool
+ {
+ if ($value === null || $value === '') {
+ return $default;
+ }
+ return in_array((string) $value, ['1', 'true', 'yes', 'on'], true);
+ }
+
+ private function isLockActive(array $state, int $nowTs): bool
+ {
+ $lockUntil = trim((string) ($state['lock_until'] ?? ''));
+ if ($lockUntil === '') {
+ return false;
+ }
+
+ $lockTs = strtotime($lockUntil);
+ return $lockTs !== false && $lockTs > $nowTs;
+ }
+
+ private function parseUtc(string $value): ?DateTimeImmutable
+ {
+ $value = trim($value);
+ if ($value === '') {
+ return null;
+ }
+
+ try {
+ return new DateTimeImmutable($value, new DateTimeZone('UTC'));
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ private function safeTimezone(string $timezone): DateTimeZone
+ {
+ try {
+ return new DateTimeZone(trim($timezone) !== '' ? trim($timezone) : 'UTC');
+ } catch (\Throwable) {
+ return new DateTimeZone('UTC');
+ }
+ }
+}
diff --git a/src/App/ModuleManager.php b/src/App/ModuleManager.php
index 059eb7f..c1a771d 100644
--- a/src/App/ModuleManager.php
+++ b/src/App/ModuleManager.php
@@ -8,6 +8,7 @@ final class ModuleManager
private array $modules = [];
private array $callbacks = [];
private ?ModuleTaskScheduler $taskScheduler = null;
+ private ?ModuleCronScheduler $cronScheduler = null;
public function __construct(
private ?\PDO $basePdo,
@@ -259,6 +260,21 @@ final class ModuleManager
return $this->taskScheduler()->runDue($name);
}
+ public function cronTasks(string $name): array
+ {
+ return $this->cronScheduler()->definitions($name);
+ }
+
+ public function cronTaskStatuses(string $name): array
+ {
+ return $this->cronScheduler()->statuses($name);
+ }
+
+ public function runDueCronTasks(string $name): array
+ {
+ return $this->cronScheduler()->runDue($name);
+ }
+
private function scanModules(): void
{
$this->modules = [];
@@ -291,6 +307,7 @@ final class ModuleManager
'description' => $data['description'] ?? '',
'setup' => $data['setup'] ?? [],
'interval_tasks' => $data['interval_tasks'] ?? [],
+ 'cron_tasks' => $data['cron_tasks'] ?? [],
'menu' => $data['menu'] ?? [],
'sidebar' => $data['sidebar'] ?? [],
'db_defaults' => $data['db_defaults'] ?? [],
@@ -496,4 +513,13 @@ final class ModuleManager
return $this->taskScheduler;
}
+
+ private function cronScheduler(): ModuleCronScheduler
+ {
+ if (!$this->cronScheduler instanceof ModuleCronScheduler) {
+ $this->cronScheduler = new ModuleCronScheduler($this, $this->basePdo);
+ }
+
+ return $this->cronScheduler;
+ }
}
diff --git a/src/App/ModuleTaskScheduler.php b/src/App/ModuleTaskScheduler.php
index 1786065..7ab4b53 100644
--- a/src/App/ModuleTaskScheduler.php
+++ b/src/App/ModuleTaskScheduler.php
@@ -71,7 +71,7 @@ final class ModuleTaskScheduler
}
$intervalHours = max(1.0, $intervalHours);
- $referenceAt = trim((string) ($state['last_success_at'] ?? $state['last_finished_at'] ?? ''));
+ $referenceAt = trim((string) ($state['last_success_at'] ?? ''));
$referenceTs = $referenceAt !== '' ? strtotime($referenceAt) : false;
$nextDueTs = $referenceTs !== false ? ((int) $referenceTs + (int) round($intervalHours * 3600)) : null;
$isLocked = $this->isLockActive($state, $nowTs);
@@ -125,16 +125,17 @@ final class ModuleTaskScheduler
'started_at' => $startedAt,
]);
$ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
+ $skipped = is_array($result) && !empty($result['skipped']);
$message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
$finishedAt = gmdate('Y-m-d H:i:s');
$payload = [
'last_finished_at' => $finishedAt,
- 'last_status' => $ok ? 'success' : 'error',
+ 'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
'last_message' => $message,
'lock_until' => null,
];
- if ($ok) {
+ if ($ok && !$skipped) {
$payload['last_success_at'] = $finishedAt;
}
$this->persistState($moduleName, (string) $task['name'], $payload);
diff --git a/tools/module_scheduler_run.php b/tools/module_scheduler_run.php
new file mode 100644
index 0000000..db2ceb1
--- /dev/null
+++ b/tools/module_scheduler_run.php
@@ -0,0 +1,38 @@
+#!/usr/bin/env php
+modules()->all();
+$results = [];
+
+foreach ($modules as $name => $meta) {
+ if (!is_string($name) || $name === '') {
+ continue;
+ }
+ if ($targetModule !== '' && $targetModule !== $name) {
+ continue;
+ }
+ if (empty($meta['enabled'])) {
+ continue;
+ }
+
+ $taskDefs = app()->modules()->intervalTasks($name);
+ if ($taskDefs === []) {
+ continue;
+ }
+
+ $results[$name] = [
+ 'interval' => app()->modules()->runDueIntervalTasks($name),
+ 'cron' => app()->modules()->runDueCronTasks($name),
+ ];
+}
+
+echo json_encode([
+ 'ok' => true,
+ 'ran_at_utc' => gmdate('Y-m-d H:i:s'),
+ 'module' => $targetModule !== '' ? $targetModule : null,
+ 'results' => $results,
+], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;