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

This commit is contained in:
2026-05-01 03:54:12 +02:00
parent d5e9d588cd
commit 86eeef71a8
4 changed files with 435 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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