diff --git a/modules/fx-rates/bootstrap.php b/modules/fx-rates/bootstrap.php index f1a921b..e7b9672 100644 --- a/modules/fx-rates/bootstrap.php +++ b/modules/fx-rates/bootstrap.php @@ -62,6 +62,7 @@ $mm->registerFunction($moduleName, 'settings', static function (): array { 'api_url' => $apiUrl, 'api_key' => $apiKey, 'timeout_sec' => $timeout, + 'refresh_max_age_minutes' => max(1, (int) ($saved['refresh_max_age_minutes'] ?? 60)), 'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR', 'display_base_currency' => strtoupper(trim((string) ($saved['display_base_currency'] ?? ($saved['default_base_currency'] ?? 'EUR')))) ?: 'EUR', 'preferred_currencies' => $preferredCurrencies, diff --git a/modules/fx-rates/module.json b/modules/fx-rates/module.json index 9f85972..508e36b 100644 --- a/modules/fx-rates/module.json +++ b/modules/fx-rates/module.json @@ -1,6 +1,6 @@ { "title": "Waehrungskurse", - "version": "0.1.4", + "version": "0.1.5", "description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.", "enabled_by_default": true, "setup": { @@ -18,6 +18,7 @@ { "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." }, { "name": "api_key", "label": "FX API Key", "type": "password", "required": false }, { "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false }, + { "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Refresh aktualisiert nur, wenn der letzte gespeicherte Abruf aelter ist." }, { "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." }, diff --git a/modules/fx-rates/src/Api/Router.php b/modules/fx-rates/src/Api/Router.php index b1ee7f0..350e907 100644 --- a/modules/fx-rates/src/Api/Router.php +++ b/modules/fx-rates/src/Api/Router.php @@ -42,6 +42,23 @@ final class Router $this->respond(['data' => $snapshot]); } + if ($path === 'v1/fetch' && $method === 'GET') { + $fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0)); + $base = $this->stringOrNull($_GET['base'] ?? null); + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $snapshot = $this->service->snapshotByFetchId($fetchId, $base, $symbols); + $this->respond(['data' => $snapshot]); + } + + if ($path === 'v1/nearest' && $method === 'GET') { + $base = $this->stringOrNull($_GET['base'] ?? null); + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $at = $this->stringOrNull($_GET['at'] ?? null); + $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); + $snapshot = $this->service->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes); + $this->respond(['data' => $snapshot]); + } + if ($path === 'v1/snapshot' && $method === 'GET') { $symbols = $this->parseCsv($_GET['symbols'] ?? null); $base = $this->stringOrNull($_GET['base'] ?? null); @@ -74,11 +91,11 @@ final class Router $input = $this->input(); $base = $this->stringOrNull($input['base'] ?? null); $force = !empty($input['force']); - $maxAgeHours = is_numeric($input['max_age_hours'] ?? null) ? (float) $input['max_age_hours'] : 24.0; + $maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null) ? (int) $input['max_age_minutes'] : null; $result = $force ? $this->service->refreshLatestRates(null, $base) - : $this->service->ensureFreshLatestRates($maxAgeHours, $base, null); + : $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes); $this->respond(['data' => $result], 201); } diff --git a/modules/fx-rates/src/Domain/FxRatesService.php b/modules/fx-rates/src/Domain/FxRatesService.php index c8d4199..00e2e2e 100644 --- a/modules/fx-rates/src/Domain/FxRatesService.php +++ b/modules/fx-rates/src/Domain/FxRatesService.php @@ -79,6 +79,58 @@ final class FxRatesService ]); } + public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array + { + if ($fetchId <= 0) { + return null; + } + + $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + if ($requestedBase === '') { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId($fetchId, null); + if ($snapshot === null) { + return null; + } + + return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $requestedBase, $symbols)); + } + + public function nearestSnapshot(?string $baseCurrency = null, string $at = '', ?array $symbols = null, ?int $windowMinutes = null): ?array + { + $timestamp = $this->normalizeTimestamp($at); + if ($timestamp === null) { + return null; + } + + $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + if ($requestedBase === '') { + return null; + } + + $nearest = $this->repository->findNearestFetch(null, $timestamp, $windowMinutes); + if ($nearest === null) { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) ($nearest['id'] ?? 0), null); + if ($snapshot === null) { + return null; + } + + $rebased = $this->rebaseSnapshot($snapshot, $requestedBase, $symbols); + if ($rebased === null) { + return null; + } + + return $this->localizeSnapshot($rebased + [ + 'requested_at' => $timestamp, + 'distance_seconds' => $nearest['distance_seconds'] ?? null, + ]); + } + public function findRate(?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array { $from = $this->normalizeCurrency($fromCurrency); @@ -193,6 +245,13 @@ final class FxRatesService return $result; } + public function autoRefreshLatestRates(?string $baseCurrency = null, ?array $currencies = null, ?int $maxAgeMinutes = null): array + { + $minutes = $maxAgeMinutes ?? $this->refreshMaxAgeMinutes(); + $hours = max(1, $minutes) / 60; + return $this->ensureFreshLatestRates($hours, $baseCurrency, $currencies); + } + public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array { $fromCurrency = $this->normalizeCurrency($fromCurrency); @@ -600,9 +659,14 @@ final class FxRatesService } if ($requestedBase === '' || $requestedBase === $snapshotBase) { + $filteredRates = $this->filterRates($rates, $symbols); + if ($this->symbolsContain($symbols, $requestedBase)) { + $filteredRates = [$requestedBase => 1.0] + $filteredRates; + } + return $snapshot + [ 'base_currency' => $snapshotBase, - 'rates' => $this->filterRates($rates, $symbols), + 'rates' => $filteredRates, ]; } @@ -620,9 +684,14 @@ final class FxRatesService $rebasedRates[$code] = (float) $rate / (float) $baseRate; } + $filteredRates = $this->filterRates($rebasedRates, $symbols); + if ($this->symbolsContain($symbols, $requestedBase)) { + $filteredRates = [$requestedBase => 1.0] + $filteredRates; + } + return $snapshot + [ 'base_currency' => $requestedBase, - 'rates' => $this->filterRates($rebasedRates, $symbols), + 'rates' => $filteredRates, 'snapshot_base_currency' => $snapshotBase, ]; } @@ -646,6 +715,21 @@ final class FxRatesService return $filtered; } + private function symbolsContain(?array $symbols, string $currency): bool + { + if (!is_array($symbols) || $symbols === []) { + return false; + } + + foreach ($symbols as $symbol) { + if ($this->normalizeCurrency((string) $symbol) === $currency) { + return true; + } + } + + return false; + } + private function crossHistory(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array { $fromAt = $this->normalizeTimestamp($from); @@ -884,6 +968,11 @@ final class FxRatesService return max(2, (int) ($this->settings['timeout_sec'] ?? 10)); } + private function refreshMaxAgeMinutes(): int + { + return max(1, (int) ($this->settings['refresh_max_age_minutes'] ?? 60)); + } + private function defaultBaseCurrency(): string { return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR'; diff --git a/modules/fx-rates/src/Infrastructure/FxRatesRepository.php b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php index 0c8d4fa..a340062 100644 --- a/modules/fx-rates/src/Infrastructure/FxRatesRepository.php +++ b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php @@ -146,6 +146,55 @@ final class FxRatesRepository ]; } + public function findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array + { + $targetTs = strtotime($timestamp); + if ($targetTs === false) { + return null; + } + + if ($baseCurrency !== null && trim($baseCurrency) !== '') { + return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes); + } + + $candidates = []; + foreach (['<=', '>='] as $operator) { + $order = $operator === '<=' ? 'DESC' : 'ASC'; + $stmt = $this->pdo->prepare( + 'SELECT id, provider, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + WHERE fetched_at ' . $operator . ' :target_at + ORDER BY fetched_at ' . $order . ', id ' . $order . ' + LIMIT 1' + ); + $stmt->execute(['target_at' => $timestamp]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (is_array($row)) { + $candidate = $this->normalizeFetch($row); + $candidateTs = strtotime((string) ($candidate['fetched_at'] ?? '')); + if ($candidateTs !== false) { + $candidate['distance_seconds'] = abs($candidateTs - $targetTs); + $candidates[] = $candidate; + } + } + } + + if ($candidates === []) { + return null; + } + + usort($candidates, static function (array $left, array $right): int { + return ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX)); + }); + + $selected = $candidates[0]; + if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) { + return null; + } + + return $selected; + } + public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array { $baseCurrency = strtoupper(trim($baseCurrency)); diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php index 969b8d7..85f61d5 100644 --- a/partials/landingpages/modules/setup.php +++ b/partials/landingpages/modules/setup.php @@ -544,6 +544,11 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) Timeout (Sek.) +