From 7f038f03e86fba7551f7fe80ee6e9182f5d5e6c6 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Sat, 2 May 2026 03:36:09 +0200 Subject: [PATCH] dsfd --- modules/boersenchecker/bootstrap.php | 166 ++++++++++++++---- modules/boersenchecker/module.json | 2 +- modules/boersenchecker/partials/dashboard.php | 9 +- .../src/Support/DashboardPage.php | 31 ++-- .../boersenchecker/src/Support/HomePage.php | 30 +++- .../src/Support/InstrumentPage.php | 15 +- 6 files changed, 189 insertions(+), 64 deletions(-) diff --git a/modules/boersenchecker/bootstrap.php b/modules/boersenchecker/bootstrap.php index 0fc7ffd..818490a 100644 --- a/modules/boersenchecker/bootstrap.php +++ b/modules/boersenchecker/bootstrap.php @@ -3,18 +3,6 @@ declare(strict_types=1); use App\ModuleConfigException; -spl_autoload_register(static function (string $class): void { - $prefix = 'Modules\\MiningChecker\\'; - if (!str_starts_with($class, $prefix)) { - return; - } - - $file = dirname(__DIR__) . '/mining-checker/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; - if (is_file($file)) { - require_once $file; - } -}); - spl_autoload_register(static function (string $class): void { $prefix = 'Modules\\Boersenchecker\\'; if (!str_starts_with($class, $prefix)) { @@ -228,34 +216,11 @@ $mm->registerFunction($moduleName, 'fx_service', static function (): ?object { try { return module_fn('fx-rates', 'service'); } catch (\Throwable) { + return null; } } - if (!is_dir(dirname(__DIR__) . '/mining-checker')) { - return null; - } - - try { - $config = \Modules\MiningChecker\Infrastructure\ModuleConfig::load(dirname(__DIR__) . '/mining-checker'); - $repo = new \Modules\MiningChecker\Infrastructure\MiningRepository( - \Modules\MiningChecker\Infrastructure\ConnectionFactory::make($config), - $config->tablePrefix() - ); - $fx = $config->fx(); - - return new \Modules\MiningChecker\Domain\FxService( - $repo, - (string) ($fx['url'] ?? 'https://currencyapi.net'), - (string) ($fx['currencies_url'] ?? ($fx['url'] ?? 'https://currencyapi.net')), - (int) ($fx['timeout'] ?? 10), - (int) ($fx['cache_ttl'] ?? 21600), - false, - (string) ($fx['provider'] ?? 'currencyapi'), - (string) ($fx['api_key'] ?? '') - ); - } catch (\Throwable) { - return null; - } + return null; }); $mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array { @@ -263,7 +228,7 @@ $mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCu if (!$service || !method_exists($service, 'ensureFreshLatestRates')) { return [ 'ok' => false, - 'message' => 'FX-Service ist aktuell nicht verfuegbar.', + 'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.', ]; } @@ -284,6 +249,119 @@ $mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCu } }); +$mm->registerFunction($moduleName, 'fx_prepare_fetch', static function ( + string $baseCurrency = 'EUR', + array $currencies = [], + float $maxAgeHours = 6.0 +): array { + $service = module_fn('boersenchecker', 'fx_service'); + if (!$service || !method_exists($service, 'ensureFreshLatestRates')) { + return [ + 'ok' => false, + 'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.', + ]; + } + + $baseCurrency = strtoupper(trim($baseCurrency)) ?: 'EUR'; + $currencies = array_values(array_unique(array_filter(array_map( + static fn (mixed $code): string => strtoupper(trim((string) $code)), + $currencies + ), static fn (string $code): bool => $code !== ''))); + + try { + $result = $service->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies); + return [ + 'ok' => true, + 'message' => !empty($result['reused']) ? 'Vorhandene FX-Daten weiterverwendet.' : 'FX-Daten aktualisiert.', + 'result' => $result, + 'fetch_id' => is_numeric($result['fetch_id'] ?? null) ? (int) $result['fetch_id'] : null, + 'reused' => !empty($result['reused']), + ]; + } catch (\Throwable $e) { + return [ + 'ok' => false, + 'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(), + ]; + } +}); + +$mm->registerFunction($moduleName, 'fx_source_with_fetch_id', static function (string $source, ?int $fetchId = null): string { + $source = trim($source) !== '' ? trim($source) : 'manual'; + if ($fetchId === null || $fetchId <= 0) { + return preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source; + } + + $source = preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source; + return $source . '|fx_fetch:' . $fetchId; +}); + +$mm->registerFunction($moduleName, 'fx_extract_fetch_id', static function (?string $source): ?int { + $source = trim((string) $source); + if ($source === '') { + return null; + } + + if (preg_match('/\|fx_fetch:(\d+)$/', $source, $matches) === 1) { + $fetchId = (int) ($matches[1] ?? 0); + return $fetchId > 0 ? $fetchId : null; + } + + return null; +}); + +$mm->registerFunction($moduleName, 'fx_convert_with_fetch', static function ( + ?float $amount, + ?string $fromCurrency, + ?string $toCurrency, + ?int $fetchId = null +): ?float { + if ($amount === null) { + return null; + } + + $from = strtoupper(trim((string) $fromCurrency)); + $to = strtoupper(trim((string) $toCurrency)); + if ($from === '' || $to === '') { + return null; + } + if ($from === $to) { + return $amount; + } + + $service = module_fn('boersenchecker', 'fx_service'); + if (!$service) { + return null; + } + + $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; + if ($normalizedFetchId !== null && method_exists($service, 'snapshotByFetchId')) { + try { + $snapshot = $service->snapshotByFetchId($normalizedFetchId, null, [$from, $to]); + if (is_array($snapshot)) { + $base = strtoupper(trim((string) ($snapshot['base_currency'] ?? ''))); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + $fromRate = $from === $base ? 1.0 : (is_numeric($rates[$from] ?? null) ? (float) $rates[$from] : null); + $toRate = $to === $base ? 1.0 : (is_numeric($rates[$to] ?? null) ? (float) $rates[$to] : null); + if ($fromRate !== null && $fromRate > 0 && $toRate !== null && $toRate > 0) { + return $amount * ($toRate / $fromRate); + } + } + } catch (\Throwable) { + } + } + + if (!method_exists($service, 'convert')) { + return null; + } + + try { + $value = $service->convert($amount, $from, $to); + return is_numeric($value) ? (float) $value : null; + } catch (\Throwable) { + return null; + } +}); + $mm->registerFunction($moduleName, 'alpha_vantage_request', static function ( string $functionName, array $params = [] @@ -648,6 +726,16 @@ $mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function ( ]; } + $quoteCurrencies = array_values(array_unique(array_filter(array_map( + static fn (array $row): string => strtoupper(trim((string) ($row['quote_currency'] ?? ''))), + $candidates + ), static fn (string $code): bool => $code !== ''))); + $fxResult = module_fn('boersenchecker', 'fx_prepare_fetch', $defaultReportCurrency, $quoteCurrencies, (float) (($settings['fx_max_age_hours'] ?? null) ?: 6)); + if (empty($fxResult['ok'])) { + return $fxResult; + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + $bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates); if (empty($bulkResult['ok'])) { return [ @@ -673,7 +761,7 @@ $mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function ( (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') + (string) module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId) ); if (!empty($storeResult['inserted'])) { $updated++; diff --git a/modules/boersenchecker/module.json b/modules/boersenchecker/module.json index 1bba81b..d7d88ae 100644 --- a/modules/boersenchecker/module.json +++ b/modules/boersenchecker/module.json @@ -14,7 +14,7 @@ { "name": "db.user", "label": "DB User", "type": "text", "required": false }, { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, { "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." }, - { "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": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber das Modul fx-rates 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." }, diff --git a/modules/boersenchecker/partials/dashboard.php b/modules/boersenchecker/partials/dashboard.php index 203dd70..f6d4706 100644 --- a/modules/boersenchecker/partials/dashboard.php +++ b/modules/boersenchecker/partials/dashboard.php @@ -77,8 +77,8 @@

- Die Umrechnung liest gespeicherte FX-Daten aus dem Mining-Checker. Eine Aktualisierung wird nur manuell - angestossen und respektiert die dortige Max-Age-Logik. + Die Umrechnung liest gespeicherte FX-Daten zentral aus dem Modul fx-rates. Eine Aktualisierung wird nur manuell + angestossen und respektiert die dortige Max-Age- und Reuse-Logik.

Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten. @@ -99,7 +99,10 @@ Alpha Vantage Mindestabstand: Min.

- API-Key und Timeout werden ueber Modul-Setup gepflegt. + API-Key und Timeout fuer Aktienkurse werden ueber dieses Modul-Setup gepflegt. +
+
+ FX-Provider, API-Key und Waehrungskatalog werden im Modul fx-rates gepflegt.
Standard-Berichtswahrung: ยท Max. Alter: h diff --git a/modules/boersenchecker/src/Support/DashboardPage.php b/modules/boersenchecker/src/Support/DashboardPage.php index 0eefdeb..93b336f 100644 --- a/modules/boersenchecker/src/Support/DashboardPage.php +++ b/modules/boersenchecker/src/Support/DashboardPage.php @@ -426,6 +426,11 @@ final class DashboardPage if (empty($apiResult['ok'])) { throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); } + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { $displayTime = (string) \module_fn( 'boersenchecker', @@ -443,7 +448,7 @@ final class DashboardPage (float) $apiResult['price'], $quoteCurrency, (string) $apiResult['fetched_at'], - (string) $apiResult['source'] + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) ); if (!empty($storeResult['inserted'])) { return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.'; @@ -498,6 +503,16 @@ final class DashboardPage } if ($bulkRows !== []) { + $quoteCurrencies = array_values(array_unique(array_filter(array_map( + fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')), + $bulkRows + )))); + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + $bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows); if (empty($bulkResult['ok'])) { throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.')); @@ -526,7 +541,7 @@ final class DashboardPage (float) $apiResult['price'], $quoteCurrency, (string) $apiResult['fetched_at'], - (string) $apiResult['source'] + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) ); if (!empty($storeResult['inserted'])) { $fetched++; @@ -698,7 +713,7 @@ final class DashboardPage if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) { $currentOriginal = $quantity * (float) $latestQuote['price']; - $currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency); + $currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency, (string) ($latestQuote['source'] ?? '')); $position['latest_price'] = (float) $latestQuote['price']; $position['latest_currency'] = (string) $latestQuote['currency']; $position['latest_quoted_at'] = (string) $latestQuote['quoted_at']; @@ -796,7 +811,7 @@ final class DashboardPage ]); } - private function convertAmount(?float $amount, string $from, string $to): ?float + private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float { if ($amount === null) { return null; @@ -808,13 +823,9 @@ final class DashboardPage return $amount; } - $fxService = \module_fn('boersenchecker', 'fx_service'); - if (!$fxService || !method_exists($fxService, 'convert')) { - return null; - } - try { - $value = $fxService->convert($amount, $from, $to); + $fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0); + $value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null); return is_numeric($value) ? (float) $value : null; } catch (\Throwable) { return null; diff --git a/modules/boersenchecker/src/Support/HomePage.php b/modules/boersenchecker/src/Support/HomePage.php index 760ee6b..fdcb209 100644 --- a/modules/boersenchecker/src/Support/HomePage.php +++ b/modules/boersenchecker/src/Support/HomePage.php @@ -15,6 +15,7 @@ final class HomePage private string $positionTable; private string $quoteTable; private string $defaultReportCurrency; + private float $fxMaxAgeHours; private int $marketDataMinIntervalMinutes; public function __construct() @@ -26,6 +27,10 @@ final class HomePage $settings = \modules()->settings('boersenchecker'); $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6); + if ($this->fxMaxAgeHours <= 0) { + $this->fxMaxAgeHours = 6.0; + } $this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); if ($this->marketDataMinIntervalMinutes <= 0) { $this->marketDataMinIntervalMinutes = 60; @@ -77,7 +82,8 @@ final class HomePage $currentReport = $this->convertAmount( $currentNative, (string) ($position['latest_currency'] ?: ($position['quote_currency'] ?? $this->defaultReportCurrency)), - $this->defaultReportCurrency + $this->defaultReportCurrency, + (string) ($position['latest_source'] ?? '') ); $position['current_total_report'] = $currentReport; if ($position['purchase_total_report'] !== null && $currentReport !== null) { @@ -156,6 +162,16 @@ final class HomePage } if ($bulkCandidates !== []) { + $quoteCurrencies = array_values(array_unique(array_filter(array_map( + fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')), + $bulkCandidates + )))); + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + $bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkCandidates); $quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; foreach ($bulkCandidates as $row) { @@ -176,7 +192,7 @@ final class HomePage (float) $quote['price'], strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, (string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')), - (string) ($quote['source'] ?? 'alphavantage:global_quote') + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId) ); if (!empty($storeResult['inserted'])) { $updated++; @@ -319,7 +335,7 @@ final class HomePage ]; } - private function convertAmount(?float $amount, string $from, string $to): ?float + private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float { if ($amount === null) { return null; @@ -331,13 +347,9 @@ final class HomePage return $amount; } - $fxService = \module_fn('boersenchecker', 'fx_service'); - if (!$fxService || !method_exists($fxService, 'convert')) { - return null; - } - try { - $value = $fxService->convert($amount, $from, $to); + $fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0); + $value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null); return is_numeric($value) ? (float) $value : null; } catch (\Throwable) { return null; diff --git a/modules/boersenchecker/src/Support/InstrumentPage.php b/modules/boersenchecker/src/Support/InstrumentPage.php index 890361a..4c599ad 100644 --- a/modules/boersenchecker/src/Support/InstrumentPage.php +++ b/modules/boersenchecker/src/Support/InstrumentPage.php @@ -14,6 +14,7 @@ final class InstrumentPage private string $positionTable; private string $quoteTable; private string $defaultReportCurrency; + private float $fxMaxAgeHours; private string $searchKeywords = ''; private array $searchResults = []; private int $selectedInstrumentOverrideId = 0; @@ -28,6 +29,10 @@ final class InstrumentPage $settings = \modules()->settings('boersenchecker'); $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6); + if ($this->fxMaxAgeHours <= 0) { + $this->fxMaxAgeHours = 6.0; + } $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name); $this->instrumentTable = $table('instruments'); $this->positionTable = $table('positions'); @@ -223,6 +228,12 @@ final class InstrumentPage if (empty($apiResult['ok'])) { throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); } + $quoteCurrency = strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency; + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { $displayTime = (string) \module_fn( @@ -239,9 +250,9 @@ final class InstrumentPage 'store_market_quote', $instrumentId, (float) $apiResult['price'], - strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, + $quoteCurrency, (string) $apiResult['fetched_at'], - (string) $apiResult['source'] + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) ); return !empty($storeResult['inserted'])