cron
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-27 00:24:30 +02:00
parent 7ce9173d57
commit e7a1878c72
7 changed files with 573 additions and 1 deletions

View File

@@ -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 === '') {

View File

@@ -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",

View File

@@ -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)
</section>
<?php endif; ?>
<?php if ($intervalTaskStatuses !== []): ?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Automationen</span>
<h2>Intervall-Aufgaben</h2>
<p class="muted">Diese Aufgaben werden beim ersten gueltigen Modulaufruf nach Ablauf des Intervalls automatisch ausgefuehrt.</p>
</div>
</div>
<div class="setup-grid">
<?php foreach ($intervalTaskStatuses as $task): ?>
<?php $state = is_array($task['state'] ?? null) ? $task['state'] : []; ?>
<div class="setup-field muted">
<span><?= e((string) ($task['label'] ?? $task['name'] ?? 'Intervall-Aufgabe')) ?></span>
<input type="text" readonly value="<?= e(!empty($task['enabled']) ? 'Aktiv' : 'Deaktiviert') ?>">
<small class="muted">Intervall: <?= e(number_format((float) ($task['interval_hours'] ?? 0), 2, ',', '')) ?> Stunden</small>
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($state['last_started_at'] ?? ''))) ?></small>
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($state['last_success_at'] ?? ''))) ?></small>
<small class="muted">Naechster Lauf: <?= e($formatRunTimestamp((string) ($task['next_due_at'] ?? ''))) ?></small>
<small class="muted">Status: <?= e((string) (($state['last_status'] ?? '') !== '' ? $state['last_status'] : '-')) ?></small>
<?php if (trim((string) ($state['last_message'] ?? '')) !== ''): ?>
<small class="muted">Meldung: <?= e((string) $state['last_message']) ?></small>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if (is_array($moduleStatusPanel)): ?>
<section class="setup-panel setup-panel--flat">
<div class="setup-panel__head">

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleTaskScheduler
{
public function __construct(
private ModuleManager $modules,
private ?\PDO $pdo
) {
}
public function definitions(string $moduleName): array
{
$module = $this->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;
}
}