From 86eeef71a811b7ba532505fc5fe57f7084df1976 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Fri, 1 May 2026 03:54:12 +0200 Subject: [PATCH] asdasd --- modules/mining-checker/assets/js/app.js | 37 ++ modules/mining-checker/docs/README.md | 3 + modules/mining-checker/src/Api/Router.php | 338 ++++++++++++++++++ .../mining-checker/src/Domain/FxService.php | 67 +++- 4 files changed, 435 insertions(+), 10 deletions(-) diff --git a/modules/mining-checker/assets/js/app.js b/modules/mining-checker/assets/js/app.js index 49302fe..7d6dd6d 100644 --- a/modules/mining-checker/assets/js/app.js +++ b/modules/mining-checker/assets/js/app.js @@ -1606,6 +1606,36 @@ } } + async function migrateLegacyFxData() { + if (!window.confirm('Legacy-FX-Rates aus dem Mining-Checker nach fx-rates migrieren und Messpunkte auf die neuen fetch_id-Verweise aktualisieren?')) { + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/legacy-fx-migrate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + timeoutMs: 30000, + }); + setMessage( + `${result.message || 'Legacy-FX-Rates wurden migriert.'} ` + + `Fetches gefunden: ${Number(result.legacy_fetches_found || 0)}, ` + + `neu importiert: ${Number(result.fx_fetches_imported || 0)}, ` + + `wiederverwendet: ${Number(result.fx_fetches_reused || 0)}, ` + + `Messpunkte aktualisiert: ${Number(result.measurements_updated || 0)}, ` + + `offen: ${Number(result.measurements_unresolved || 0)}.` + ); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + async function importSqlFile() { if (!sqlImportFile) { setError('Bitte zuerst eine SQL-Datei auswaehlen.'); @@ -2881,6 +2911,13 @@ onClick: importOldData, disabled: saving, }, saving ? 'Importiert …' : 'Alte Daten importieren'), + h('button', { + key: 'legacy-fx-migrate', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: migrateLegacyFxData, + disabled: saving, + }, saving ? 'Migriert …' : 'Legacy FX zu fx-rates migrieren'), h('div', { key: 'sql-import', className: 'mc-form' }, [ h('label', { className: 'mc-field' }, [ h('span', { className: 'mc-field-label' }, 'SQL-Datei importieren'), diff --git a/modules/mining-checker/docs/README.md b/modules/mining-checker/docs/README.md index 1052e63..26d7500 100644 --- a/modules/mining-checker/docs/README.md +++ b/modules/mining-checker/docs/README.md @@ -51,6 +51,7 @@ modules/mining-checker/ - `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh` - `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh` - `GET /api/mining-checker/v1/projects/{projectKey}/fx-history` +- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` ## Integration @@ -118,6 +119,8 @@ Beispiel: Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet. +Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert. + Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot. Mit `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh` kann die Waehrungstabelle einmalig oder bei Bedarf aus `GET /api/v2/currencies?output=json&key=...` synchronisiert werden. Dabei werden Code, Name, Symbol und Sortierung in `miningcheck_currencies` gespeichert. diff --git a/modules/mining-checker/src/Api/Router.php b/modules/mining-checker/src/Api/Router.php index 33a2989..1c8204a 100644 --- a/modules/mining-checker/src/Api/Router.php +++ b/modules/mining-checker/src/Api/Router.php @@ -149,6 +149,10 @@ final class Router $this->respond(['data' => $this->fxHistory()]); } + if ($resource === 'legacy-fx-migrate' && $method === 'POST') { + $this->respond(['data' => $this->migrateLegacyFxRates($projectKey)], 201); + } + if ($resource === 'bootstrap' && $method === 'GET') { $this->respond(['data' => $this->bootstrap($projectKey)]); } @@ -731,6 +735,340 @@ final class Router return $rows; } + private function migrateLegacyFxRates(string $projectKey): array + { + if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'repository')) { + throw new ApiException('Das Modul fx-rates ist nicht verfuegbar.', 422); + } + + module_fn('fx-rates', 'ensure_schema'); + $legacyRows = $this->repository()->listAllFxRates(); + $legacyFetches = $this->groupLegacyFxFetches($legacyRows); + $fxRepository = module_fn('fx-rates', 'repository'); + + $fetchIdMap = []; + $importedFetches = 0; + $reusedFetches = 0; + $migratedRates = 0; + + foreach ($legacyFetches as $legacyFetchId => $legacyFetch) { + $existing = $this->findMatchingFxRatesFetch($legacyFetch); + if (is_array($existing) && !empty($existing['id'])) { + $fetchIdMap[$legacyFetchId] = (int) $existing['id']; + $reusedFetches++; + continue; + } + + $saved = $fxRepository->saveFetch( + (string) $legacyFetch['base_currency'], + (string) $legacyFetch['provider'], + (string) $legacyFetch['rate_date'], + (array) $legacyFetch['rates'], + is_string($legacyFetch['fetched_at'] ?? null) ? $legacyFetch['fetched_at'] : null, + 'migration' + ); + $newFetchId = is_numeric($saved['fetch']['id'] ?? null) ? (int) $saved['fetch']['id'] : 0; + if ($newFetchId > 0) { + $fetchIdMap[$legacyFetchId] = $newFetchId; + } + $importedFetches++; + $migratedRates += count($saved['rates'] ?? []); + } + + $measurements = $this->repository()->listAllMeasurements($projectKey); + $measurementRatesById = $this->groupMeasurementRatesByMeasurementId( + $this->repository()->listMeasurementRates($projectKey) + ); + + $updatedMeasurements = 0; + $reusedMeasurements = 0; + $unresolvedMeasurements = 0; + + foreach ($measurements as $measurement) { + $measurementId = is_numeric($measurement['id'] ?? null) ? (int) $measurement['id'] : 0; + if ($measurementId <= 0) { + continue; + } + + $currentFetchId = is_numeric($measurement['fx_fetch_id'] ?? null) ? (int) $measurement['fx_fetch_id'] : 0; + if ($currentFetchId > 0 && $this->fx()->snapshotByFetchId($currentFetchId, null, null) !== null) { + $reusedMeasurements++; + continue; + } + + $legacyFetchId = $this->resolveLegacyMeasurementFetchId( + $measurement, + $measurementRatesById[$measurementId] ?? [], + $legacyFetches + ); + $resolvedFetchId = $legacyFetchId !== null ? ($fetchIdMap[$legacyFetchId] ?? null) : null; + + if (!is_numeric($resolvedFetchId) || (int) $resolvedFetchId <= 0) { + $nearestSnapshot = $this->fx()->nearestSnapshot(null, (string) ($measurement['measured_at'] ?? ''), null, null); + $resolvedFetchId = is_numeric($nearestSnapshot['id'] ?? null) ? (int) $nearestSnapshot['id'] : null; + } + + if (!is_numeric($resolvedFetchId) || (int) $resolvedFetchId <= 0) { + $unresolvedMeasurements++; + continue; + } + + $this->repository()->setMeasurementFxFetchId($projectKey, $measurementId, (int) $resolvedFetchId); + $updatedMeasurements++; + } + + return [ + 'message' => 'Legacy-FX-Rates wurden nach fx-rates migriert und Messpunkte aktualisiert.', + 'legacy_fetches_found' => count($legacyFetches), + 'legacy_rates_found' => count($legacyRows), + 'fx_fetches_imported' => $importedFetches, + 'fx_fetches_reused' => $reusedFetches, + 'fx_rates_imported' => $migratedRates, + 'measurements_checked' => count($measurements), + 'measurements_updated' => $updatedMeasurements, + 'measurements_reused' => $reusedMeasurements, + 'measurements_unresolved' => $unresolvedMeasurements, + ]; + } + + private function groupLegacyFxFetches(array $rows): array + { + $grouped = []; + foreach ($rows as $row) { + $legacyFetchId = is_numeric($row['fetch_id'] ?? null) ? (int) $row['fetch_id'] : 0; + $baseCurrency = strtoupper(trim((string) ($row['base_currency'] ?? ''))); + $targetCurrency = strtoupper(trim((string) ($row['target_currency'] ?? ''))); + if ($legacyFetchId <= 0 || $baseCurrency === '' || $targetCurrency === '' || !is_numeric($row['rate'] ?? null)) { + continue; + } + + if (!isset($grouped[$legacyFetchId])) { + $grouped[$legacyFetchId] = [ + 'legacy_fetch_id' => $legacyFetchId, + 'base_currency' => $baseCurrency, + 'provider' => trim((string) ($row['provider'] ?? '')) !== '' ? (string) $row['provider'] : 'currencyapi', + 'rate_date' => trim((string) ($row['rate_date'] ?? '')) !== '' ? (string) $row['rate_date'] : date('Y-m-d'), + 'fetched_at' => $this->normalizeTimestamp((string) ($row['fetched_at'] ?? '')), + 'rates' => [], + ]; + } + + $grouped[$legacyFetchId]['rates'][$targetCurrency] = (float) $row['rate']; + } + + return $grouped; + } + + private function groupMeasurementRatesByMeasurementId(array $rows): array + { + $grouped = []; + foreach ($rows as $row) { + $measurementId = is_numeric($row['measurement_id'] ?? null) ? (int) $row['measurement_id'] : 0; + if ($measurementId <= 0) { + continue; + } + $grouped[$measurementId][] = $row; + } + + return $grouped; + } + + private function findMatchingFxRatesFetch(array $legacyFetch): ?array + { + $baseCurrency = strtoupper(trim((string) ($legacyFetch['base_currency'] ?? ''))); + $fetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? '')); + if ($baseCurrency === '' || $fetchedAt === null) { + return null; + } + + $snapshot = $this->fx()->nearestSnapshot($baseCurrency, $fetchedAt, null, 1); + if (!is_array($snapshot)) { + return null; + } + + $snapshotFetchedAt = $this->normalizeTimestamp((string) ($snapshot['fetched_at'] ?? '')); + if ($snapshotFetchedAt !== $fetchedAt) { + return null; + } + + return $this->snapshotMatchesLegacyFetch($snapshot, $legacyFetch) ? $snapshot : null; + } + + private function snapshotMatchesLegacyFetch(array $snapshot, array $legacyFetch): bool + { + $snapshotBase = strtoupper(trim((string) ($snapshot['base_currency'] ?? ''))); + $legacyBase = strtoupper(trim((string) ($legacyFetch['base_currency'] ?? ''))); + if ($snapshotBase === '' || $snapshotBase !== $legacyBase) { + return false; + } + + $snapshotRates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + $legacyRates = is_array($legacyFetch['rates'] ?? null) ? $legacyFetch['rates'] : []; + if ($legacyRates === []) { + return false; + } + + foreach ($legacyRates as $currencyCode => $rate) { + $currencyCode = strtoupper(trim((string) $currencyCode)); + if ($currencyCode === '' || !is_numeric($rate) || !array_key_exists($currencyCode, $snapshotRates) || !is_numeric($snapshotRates[$currencyCode])) { + return false; + } + + if (!$this->ratesAreEquivalent((float) $snapshotRates[$currencyCode], (float) $rate)) { + return false; + } + } + + return true; + } + + private function resolveLegacyMeasurementFetchId(array $measurement, array $measurementRates, array $legacyFetches): ?int + { + $measuredAt = $this->normalizeTimestamp((string) ($measurement['measured_at'] ?? '')); + if ($measuredAt === null || $legacyFetches === []) { + return null; + } + + $matches = []; + foreach ($legacyFetches as $legacyFetchId => $legacyFetch) { + if (!$this->measurementRatesMatchLegacyFetch($measurementRates, $legacyFetch)) { + continue; + } + + $legacyFetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? '')); + $distanceSeconds = $this->timestampDistanceSeconds($measuredAt, $legacyFetchedAt); + $matches[] = [ + 'legacy_fetch_id' => (int) $legacyFetchId, + 'distance_seconds' => $distanceSeconds ?? PHP_INT_MAX, + ]; + } + + if ($matches === []) { + return $this->nearestLegacyFetchId($measuredAt, $legacyFetches); + } + + usort($matches, static function (array $left, array $right): int { + return ((int) $left['distance_seconds']) <=> ((int) $right['distance_seconds']); + }); + + return (int) $matches[0]['legacy_fetch_id']; + } + + private function measurementRatesMatchLegacyFetch(array $measurementRates, array $legacyFetch): bool + { + if ($measurementRates === []) { + return true; + } + + foreach ($measurementRates as $measurementRate) { + $baseCurrency = strtoupper(trim((string) ($measurementRate['base_currency'] ?? ''))); + $quoteCurrency = strtoupper(trim((string) ($measurementRate['quote_currency'] ?? ''))); + $expectedRate = is_numeric($measurementRate['rate'] ?? null) ? (float) $measurementRate['rate'] : null; + if ($baseCurrency === '' || $quoteCurrency === '' || $expectedRate === null) { + return false; + } + + $resolvedRate = $this->resolveLegacyFetchRate($legacyFetch, $baseCurrency, $quoteCurrency); + if ($resolvedRate === null || !$this->ratesAreEquivalent($resolvedRate, $expectedRate)) { + return false; + } + } + + return true; + } + + private function resolveLegacyFetchRate(array $legacyFetch, string $baseCurrency, string $quoteCurrency): ?float + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $quoteCurrency = strtoupper(trim($quoteCurrency)); + $fetchBase = strtoupper(trim((string) ($legacyFetch['base_currency'] ?? ''))); + $rates = is_array($legacyFetch['rates'] ?? null) ? $legacyFetch['rates'] : []; + + if ($baseCurrency === '' || $quoteCurrency === '' || $fetchBase === '') { + return null; + } + + if ($baseCurrency === $quoteCurrency) { + return 1.0; + } + + if ($baseCurrency === $fetchBase && array_key_exists($quoteCurrency, $rates) && is_numeric($rates[$quoteCurrency])) { + return (float) $rates[$quoteCurrency]; + } + + if ($quoteCurrency === $fetchBase && array_key_exists($baseCurrency, $rates) && is_numeric($rates[$baseCurrency]) && (float) $rates[$baseCurrency] != 0.0) { + return 1.0 / (float) $rates[$baseCurrency]; + } + + if ( + array_key_exists($baseCurrency, $rates) + && array_key_exists($quoteCurrency, $rates) + && is_numeric($rates[$baseCurrency]) + && is_numeric($rates[$quoteCurrency]) + && (float) $rates[$baseCurrency] != 0.0 + ) { + return (float) $rates[$quoteCurrency] / (float) $rates[$baseCurrency]; + } + + return null; + } + + private function nearestLegacyFetchId(string $measuredAt, array $legacyFetches): ?int + { + $nearestFetchId = null; + $nearestDistance = null; + foreach ($legacyFetches as $legacyFetchId => $legacyFetch) { + $legacyFetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? '')); + $distance = $this->timestampDistanceSeconds($measuredAt, $legacyFetchedAt); + if ($distance === null) { + continue; + } + + if ($nearestDistance === null || $distance < $nearestDistance) { + $nearestDistance = $distance; + $nearestFetchId = (int) $legacyFetchId; + } + } + + return $nearestFetchId; + } + + private function normalizeTimestamp(string $value): ?string + { + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + try { + return (new \DateTimeImmutable($normalized))->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return null; + } + } + + private function timestampDistanceSeconds(?string $left, ?string $right): ?int + { + if ($left === null || $right === null) { + return null; + } + + $leftTs = strtotime($left); + $rightTs = strtotime($right); + if ($leftTs === false || $rightTs === false) { + return null; + } + + return abs($leftTs - $rightTs); + } + + private function ratesAreEquivalent(float $left, float $right): bool + { + $diff = abs($left - $right); + $tolerance = max(0.00000001, max(abs($left), abs($right)) * 0.000001); + return $diff <= $tolerance; + } + private function settings(string $projectKey): array { $settings = $this->repository()->getSettings($projectKey); diff --git a/modules/mining-checker/src/Domain/FxService.php b/modules/mining-checker/src/Domain/FxService.php index 199eb53..ebe1d24 100644 --- a/modules/mining-checker/src/Domain/FxService.php +++ b/modules/mining-checker/src/Domain/FxService.php @@ -17,6 +17,7 @@ final class FxService private int $cacheTtl; private bool $autoFetchOnMiss; private array $memoryCache = []; + private array $snapshotCache = []; private ?DebugTrace $debug; public function __construct( @@ -55,8 +56,8 @@ final class FxService $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; $shared = $this->sharedFxService(); - if ($shared !== null && $normalizedFetchId !== null && method_exists($shared, 'snapshotByFetchId')) { - $snapshot = $shared->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]); + if ($shared !== null && $normalizedFetchId !== null) { + $snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]); if (is_array($snapshot)) { $resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to))); if ($resolved !== null) { @@ -94,8 +95,8 @@ final class FxService } $shared = $this->sharedFxService(); - if ($shared !== null && $normalizedFetchId !== null && method_exists($shared, 'snapshotByFetchId')) { - $snapshot = $shared->snapshotByFetchId($normalizedFetchId, $base, [$target]); + if ($shared !== null && $normalizedFetchId !== null) { + $snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]); if (is_array($snapshot)) { $resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target); if ($resolved !== null) { @@ -144,35 +145,62 @@ final class FxService return null; } + $cacheKey = $this->snapshotCacheKey('fetch', [ + $fetchId, + strtoupper(trim((string) ($baseCurrency ?? ''))), + $this->normalizeSymbolsForCache($symbols), + ]); + if (array_key_exists($cacheKey, $this->snapshotCache)) { + return $this->snapshotCache[$cacheKey]; + } + $shared = $this->sharedFxService(); if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) { $snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols); - return is_array($snapshot) ? $snapshot : null; + return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null); } - return null; + return $this->snapshotCache[$cacheKey] = null; } public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array { + $cacheKey = $this->snapshotCacheKey('latest', [ + strtoupper(trim((string) ($baseCurrency ?? ''))), + $this->normalizeSymbolsForCache($symbols), + ]); + if (array_key_exists($cacheKey, $this->snapshotCache)) { + return $this->snapshotCache[$cacheKey]; + } + $shared = $this->sharedFxService(); if ($shared !== null && method_exists($shared, 'snapshot')) { $snapshot = $shared->snapshot($baseCurrency, null, $symbols, null); - return is_array($snapshot) ? $snapshot : null; + return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null); } - return null; + return $this->snapshotCache[$cacheKey] = null; } public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array { + $cacheKey = $this->snapshotCacheKey('nearest', [ + strtoupper(trim((string) ($baseCurrency ?? ''))), + trim($at), + $windowMinutes ?? 0, + $this->normalizeSymbolsForCache($symbols), + ]); + if (array_key_exists($cacheKey, $this->snapshotCache)) { + return $this->snapshotCache[$cacheKey]; + } + $shared = $this->sharedFxService(); if ($shared !== null && method_exists($shared, 'nearestSnapshot')) { $snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes); - return is_array($snapshot) ? $snapshot : null; + return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null); } - return null; + return $this->snapshotCache[$cacheKey] = null; } public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array @@ -899,4 +927,23 @@ final class FxService return null; } + + private function snapshotCacheKey(string $prefix, array $parts): string + { + return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts)); + } + + private function normalizeSymbolsForCache(?array $symbols): string + { + if (!is_array($symbols) || $symbols === []) { + return '*'; + } + + $normalized = array_values(array_unique(array_filter(array_map( + static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)), + $symbols + )))); + sort($normalized); + return implode(',', $normalized); + } }