diff --git a/modules/boersenchecker/bootstrap.php b/modules/boersenchecker/bootstrap.php index ed6cd28..653ae0b 100644 --- a/modules/boersenchecker/bootstrap.php +++ b/modules/boersenchecker/bootstrap.php @@ -567,6 +567,136 @@ $mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function ]; }); +$mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function (array $context = []): array { + $pdo = module_fn('boersenchecker', 'pdo'); + $settings = modules()->settings('boersenchecker'); + $instrumentTable = module_fn('boersenchecker', 'table', 'instruments'); + $positionTable = module_fn('boersenchecker', 'table', 'positions'); + $quoteTable = module_fn('boersenchecker', 'table', 'quotes'); + + $defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $minIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); + if ($minIntervalMinutes <= 0) { + $minIntervalMinutes = 60; + } + + $stmt = $pdo->query( + 'SELECT DISTINCT + i.id, + i.name, + i.symbol, + i.quote_currency + FROM ' . $positionTable . ' p + INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id + WHERE i.symbol IS NOT NULL + AND i.symbol <> \'\' + ORDER BY i.name ASC' + ); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($rows === []) { + return [ + 'ok' => true, + 'message' => 'Kein automatischer Kursabruf: keine Aktien mit Symbol vorhanden.', + ]; + } + + $instrumentIds = array_values(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows)); + $instrumentIds = array_values(array_filter($instrumentIds, static fn (int $id): bool => $id > 0)); + $latestQuotes = []; + if ($instrumentIds !== []) { + $placeholders = implode(',', array_fill(0, count($instrumentIds), '?')); + $latestStmt = $pdo->prepare( + 'SELECT * + FROM ' . $quoteTable . ' + WHERE instrument_id IN (' . $placeholders . ') + AND source LIKE ? + ORDER BY quoted_at DESC, created_at DESC, id DESC' + ); + $latestStmt->execute([...$instrumentIds, 'alphavantage:%']); + foreach ($latestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $instrumentId = (int) ($row['instrument_id'] ?? 0); + if ($instrumentId > 0 && !isset($latestQuotes[$instrumentId])) { + $latestQuotes[$instrumentId] = $row; + } + } + } + + $reused = 0; + $candidates = []; + foreach ($rows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $latest = $latestQuotes[$instrumentId] ?? null; + $latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($minIntervalMinutes * 60)) { + $reused++; + continue; + } + $candidates[] = $row; + } + + if ($candidates === []) { + return [ + 'ok' => true, + 'message' => 'Automatischer Kursabruf uebersprungen: alle Kurse liegen noch innerhalb des Mindestabstands.', + ]; + } + + $bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates); + if (empty($bulkResult['ok'])) { + return [ + 'ok' => false, + 'message' => (string) ($bulkResult['message'] ?? 'Automatischer Alpha-Vantage-Abruf fehlgeschlagen.'), + ]; + } + + $quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; + $errors = is_array($bulkResult['errors'] ?? null) ? $bulkResult['errors'] : []; + $updated = 0; + foreach ($candidates as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $quote = $quotes[$instrumentId] ?? null; + if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) { + continue; + } + + $storeResult = module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $quote['price'], + strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $defaultReportCurrency))) ?: $defaultReportCurrency, + (string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')), + (string) ($quote['source'] ?? 'alphavantage:global_quote') + ); + if (!empty($storeResult['inserted'])) { + $updated++; + } else { + $reused++; + } + } + + $message = 'Automatischer Kursabruf: ' . $updated . ' neu, ' . $reused . ' wiederverwendet, ' . count($errors) . ' Fehler.'; + if ($errors !== []) { + $message .= ' ' . implode(' | ', array_slice($errors, 0, 3)); + } + + module_debug_push('boersenchecker', [ + 'label' => 'Intervall-Aufgabe', + 'type' => 'scheduler:run', + 'task' => 'auto_refresh_quotes', + 'context' => $context, + 'message' => $message, + ]); + + return [ + 'ok' => $errors === [], + 'message' => $message, + 'updated' => $updated, + 'reused' => $reused, + 'errors' => $errors, + ]; +}); + $mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array { $keywords = trim($keywords); if ($keywords === '') { diff --git a/modules/boersenchecker/module.json b/modules/boersenchecker/module.json index c8b747d..1bba81b 100644 --- a/modules/boersenchecker/module.json +++ b/modules/boersenchecker/module.json @@ -17,9 +17,23 @@ { "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber den Mining-Checker genutzt." }, { "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Alpha Vantage." }, { "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." }, - { "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." } + { "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." }, + { "name": "auto_refresh_quotes_enabled", "label": "Automatischen Kursabruf aktivieren", "type": "checkbox", "required": false, "help": "Fuehrt Kursupdates automatisch beim ersten Modulaufruf nach Ablauf des Intervalls aus." }, + { "name": "auto_refresh_quotes_interval_hours", "label": "Intervall fuer automatischen Kursabruf (Stunden)", "type": "number", "required": false, "help": "Nach Ablauf dieses Intervalls wird beim naechsten Modulaufruf ein automatischer Kursabruf gestartet." } ] }, + "interval_tasks": [ + { + "name": "auto_refresh_quotes", + "label": "Automatischer Kursabruf", + "callback": "scheduled_refresh_quotes", + "enabled_setting": "auto_refresh_quotes_enabled", + "interval_setting": "auto_refresh_quotes_interval_hours", + "default_enabled": false, + "default_interval_hours": 6, + "lock_minutes": 20 + } + ], "db_defaults": { "driver": "pgsql", "host": "localhost", diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php index dfa7ef9..c2a313e 100644 --- a/partials/landingpages/modules/setup.php +++ b/partials/landingpages/modules/setup.php @@ -26,6 +26,7 @@ foreach ($fields as $field) { $fieldMeta[$fname] = $field; } $current = modules()->settings($moduleName); +$intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName); $defaults = $module['db_defaults'] ?? []; if (empty($current['db']) && is_array($defaults)) { $current['db'] = $defaults; @@ -251,6 +252,20 @@ $normalizeDriver = static function (mixed $value): mixed { }; }; +$formatRunTimestamp = static function (?string $value): string { + $value = trim((string) $value); + if ($value === '') { + return '-'; + } + + $ts = strtotime($value); + if ($ts === false) { + return $value; + } + + return date('Y-m-d H:i:s', $ts); +}; + $renderField = function (array $field) use (&$current, $getNested, $driverOptions): void { $name = (string)($field['name'] ?? ''); if ($name === '') { @@ -435,6 +450,35 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) + +
+
+
+ Automationen +

Intervall-Aufgaben

+

Diese Aufgaben werden beim ersten gueltigen Modulaufruf nach Ablauf des Intervalls automatisch ausgefuehrt.

+
+
+
+ + +
+ + + Intervall: Stunden + Letzter Start: + Letzter Erfolg: + Naechster Lauf: + Status: + + Meldung: + +
+ +
+
+ +
diff --git a/public/index.php b/public/index.php index 9d32f19..56e8602 100755 --- a/public/index.php +++ b/public/index.php @@ -231,6 +231,7 @@ if (str_starts_with($uriPath, 'modules/install')) { require_once $moduleBootstrap; } if ($modulePage) { + app()->modules()->runDueIntervalTasks($module); $target = $modulePage; } else { http_response_code(404); diff --git a/src/App/BaseSchema.php b/src/App/BaseSchema.php index 7df554e..e47b0af 100644 --- a/src/App/BaseSchema.php +++ b/src/App/BaseSchema.php @@ -61,6 +61,21 @@ final class BaseSchema )" ); + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_module_task_runs ( + module_name TEXT NOT NULL, + task_name TEXT NOT 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, task_name) + )" + ); + $pdo->exec( "CREATE TABLE IF NOT EXISTS nexus_auth_users ( sub TEXT PRIMARY KEY, @@ -149,6 +164,21 @@ final class BaseSchema )" ); + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_module_task_runs ( + module_name TEXT NOT NULL, + task_name TEXT NOT 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, task_name) + )" + ); + $pdo->exec( "CREATE TABLE IF NOT EXISTS nexus_auth_users ( sub TEXT PRIMARY KEY, @@ -237,6 +267,21 @@ final class BaseSchema )" ); + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_module_task_runs ( + module_name VARCHAR(190) NOT NULL, + task_name VARCHAR(190) NOT 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, task_name) + )" + ); + $pdo->exec( "CREATE TABLE IF NOT EXISTS nexus_auth_users ( sub VARCHAR(190) PRIMARY KEY, diff --git a/src/App/ModuleManager.php b/src/App/ModuleManager.php index 7fed9e8..059eb7f 100644 --- a/src/App/ModuleManager.php +++ b/src/App/ModuleManager.php @@ -7,6 +7,7 @@ final class ModuleManager { private array $modules = []; private array $callbacks = []; + private ?ModuleTaskScheduler $taskScheduler = null; public function __construct( private ?\PDO $basePdo, @@ -243,6 +244,21 @@ final class ModuleManager return isset($this->callbacks[$module . ':' . $name]); } + public function intervalTasks(string $name): array + { + return $this->taskScheduler()->definitions($name); + } + + public function intervalTaskStatuses(string $name): array + { + return $this->taskScheduler()->statuses($name); + } + + public function runDueIntervalTasks(string $name): array + { + return $this->taskScheduler()->runDue($name); + } + private function scanModules(): void { $this->modules = []; @@ -274,6 +290,7 @@ final class ModuleManager 'version' => $data['version'] ?? '', 'description' => $data['description'] ?? '', 'setup' => $data['setup'] ?? [], + 'interval_tasks' => $data['interval_tasks'] ?? [], 'menu' => $data['menu'] ?? [], 'sidebar' => $data['sidebar'] ?? [], 'db_defaults' => $data['db_defaults'] ?? [], @@ -470,4 +487,13 @@ final class ModuleManager return array_values(array_unique($normalized)); } + + private function taskScheduler(): ModuleTaskScheduler + { + if (!$this->taskScheduler instanceof ModuleTaskScheduler) { + $this->taskScheduler = new ModuleTaskScheduler($this, $this->basePdo); + } + + return $this->taskScheduler; + } } diff --git a/src/App/ModuleTaskScheduler.php b/src/App/ModuleTaskScheduler.php new file mode 100644 index 0000000..1786065 --- /dev/null +++ b/src/App/ModuleTaskScheduler.php @@ -0,0 +1,312 @@ +modules->get($moduleName); + $tasks = is_array($module['interval_tasks'] ?? null) ? $module['interval_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, + 'enabled_setting' => trim((string) ($task['enabled_setting'] ?? '')), + 'interval_setting' => trim((string) ($task['interval_setting'] ?? '')), + 'default_enabled' => array_key_exists('default_enabled', $task) ? (bool) $task['default_enabled'] : false, + 'default_interval_hours' => max(1.0, (float) ($task['default_interval_hours'] ?? 6)), + 'lock_minutes' => max(1, (int) ($task['lock_minutes'] ?? 10)), + ]; + } + + return $definitions; + } + + public function statuses(string $moduleName): array + { + $definitions = $this->definitions($moduleName); + if ($definitions === []) { + return []; + } + + $settings = $this->modules->settings($moduleName); + $states = $this->fetchStates($moduleName); + $nowTs = time(); + $statuses = []; + + foreach ($definitions as $name => $definition) { + $state = $states[$name] ?? []; + $enabledSetting = $definition['enabled_setting']; + $intervalSetting = $definition['interval_setting']; + + $enabled = $definition['default_enabled']; + if ($enabledSetting !== '') { + $enabled = $this->settingBool($settings[$enabledSetting] ?? null, $definition['default_enabled']); + } + + $intervalHours = $definition['default_interval_hours']; + if ($intervalSetting !== '') { + $intervalHours = $this->settingFloat($settings[$intervalSetting] ?? null, $definition['default_interval_hours']); + } + $intervalHours = max(1.0, $intervalHours); + + $referenceAt = trim((string) ($state['last_success_at'] ?? $state['last_finished_at'] ?? '')); + $referenceTs = $referenceAt !== '' ? strtotime($referenceAt) : false; + $nextDueTs = $referenceTs !== false ? ((int) $referenceTs + (int) round($intervalHours * 3600)) : null; + $isLocked = $this->isLockActive($state, $nowTs); + $isDue = $enabled && !$isLocked && ($nextDueTs === null || $nextDueTs <= $nowTs); + + $statuses[] = $definition + [ + 'enabled' => $enabled, + 'interval_hours' => $intervalHours, + 'state' => $state, + 'is_due' => $isDue, + 'is_locked' => $isLocked, + 'next_due_at' => $nextDueTs !== null ? gmdate('Y-m-d H:i:s', $nextDueTs) : null, + ]; + } + + return $statuses; + } + + public function runDue(string $moduleName): array + { + $results = []; + foreach ($this->statuses($moduleName) as $task) { + if (empty($task['enabled']) || empty($task['is_due'])) { + 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'); + $this->persistState($moduleName, (string) $task['name'], [ + 'last_started_at' => $startedAt, + 'last_status' => 'running', + 'last_message' => 'Automatischer Lauf gestartet.', + ]); + + try { + $result = $this->modules->call($moduleName, (string) $task['callback'], [ + 'task' => $task, + 'trigger' => 'interval_runner', + 'started_at' => $startedAt, + ]); + $ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']); + $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_message' => $message, + 'lock_until' => null, + ]; + if ($ok) { + $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, + ]); + $results[] = [ + 'task' => $task['name'], + 'ok' => false, + 'message' => $e->getMessage(), + ]; + } + } + + return $results; + } + + private function fetchStates(string $moduleName): array + { + if (!$this->pdo) { + return []; + } + + $stmt = $this->pdo->prepare( + "SELECT * + FROM nexus_module_task_runs + WHERE module_name = :module_name" + ); + $stmt->execute(['module_name' => $moduleName]); + + $states = []; + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) { + $name = trim((string) ($row['task_name'] ?? '')); + if ($name !== '') { + $states[$name] = $row; + } + } + + return $states; + } + + private function acquireLock(string $moduleName, string $taskName, int $lockMinutes): bool + { + if (!$this->pdo) { + return true; + } + + $states = $this->fetchStates($moduleName); + $state = $states[$taskName] ?? []; + if ($this->isLockActive($state, time())) { + return false; + } + + $lockUntil = gmdate('Y-m-d H:i:s', time() + ($lockMinutes * 60)); + $this->persistState($moduleName, $taskName, [ + 'lock_until' => $lockUntil, + ]); + + return true; + } + + private function persistState(string $moduleName, string $taskName, array $values): void + { + if (!$this->pdo) { + return; + } + + $current = $this->fetchStates($moduleName)[$taskName] ?? []; + $payload = array_merge($current, $values, [ + 'module_name' => $moduleName, + 'task_name' => $taskName, + 'updated_at' => gmdate('Y-m-d H:i:s'), + ]); + + $params = [ + 'module_name' => $moduleName, + 'task_name' => $taskName, + '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_task_runs + SET 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 task_name = :task_name" + ); + $updateStmt->execute($params); + if ($updateStmt->rowCount() > 0) { + return; + } + + $insertStmt = $this->pdo->prepare( + "INSERT INTO nexus_module_task_runs ( + module_name, + task_name, + last_started_at, + last_finished_at, + last_success_at, + last_status, + last_message, + lock_until, + updated_at + ) VALUES ( + :module_name, + :task_name, + :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 settingFloat(mixed $value, float $default): float + { + if (!is_numeric($value)) { + return $default; + } + + $numeric = (float) $value; + return $numeric > 0 ? $numeric : $default; + } + + 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; + } +}