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

This commit is contained in:
2026-05-01 04:00:24 +02:00
parent 86eeef71a8
commit 2de8c95fdb
2 changed files with 83 additions and 59 deletions

View File

@@ -385,6 +385,30 @@ final class FxRatesRepository
}
}
public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array
{
$baseCurrency = strtoupper(trim($baseCurrency));
$fetchedAt = trim($fetchedAt);
if ($baseCurrency === '' || $fetchedAt === '') {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE base_currency = :base_currency
AND fetched_at = :fetched_at
ORDER BY id ASC
LIMIT 1'
);
$stmt->execute([
'base_currency' => $baseCurrency,
'fetched_at' => $fetchedAt,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
private function getFetchById(int $fetchId): ?array
{
$stmt = $this->pdo->prepare(
@@ -496,7 +520,7 @@ final class FxRatesRepository
{
$source = strtolower(trim($source));
return match ($source) {
'cron', 'manual', 'api' => $source,
'cron', 'manual', 'api', 'migration' => $source,
default => 'manual',
};
}

View File

@@ -61,6 +61,7 @@ final class Router
public function handle(string $relativePath): never
{
try {
$this->releaseSessionLock();
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$path = trim($relativePath, '/');
@@ -293,11 +294,38 @@ final class Router
private function bootstrap(string $projectKey): array
{
$settings = $this->settings($projectKey);
$measurements = $this->measurements($projectKey, $settings);
$targets = $this->targets($projectKey);
$dashboards = $this->dashboards($projectKey);
$fxSnapshots = $this->measurementFxSnapshots($measurements);
$startedAt = microtime(true);
$settings = $this->safeRead(fn () => $this->settings($projectKey), [
'project_key' => $projectKey,
'baseline_measured_at' => null,
'baseline_coins_total' => null,
'daily_cost_amount' => null,
'daily_cost_currency' => 'EUR',
'report_currency' => 'EUR',
'crypto_currency' => 'DOGE',
'display_timezone' => 'Europe/Berlin',
'fx_max_age_hours' => 3,
'module_theme_mode' => 'inherit',
'module_theme_accent' => 'teal',
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
'cost_plans' => [],
'currencies' => [],
'payouts' => [],
'miner_offers' => [],
'purchased_miners' => [],
'measurement_rates' => [],
]);
$measurements = $this->safeRead(fn () => $this->measurements($projectKey, $settings, false), []);
$targets = $this->safeRead(fn () => $this->targets($projectKey), []);
$dashboards = $this->safeRead(fn () => $this->dashboards($projectKey), []);
$fxSnapshots = $this->safeRead(fn () => $this->measurementFxSnapshots($measurements), []);
$summary = $this->safeRead(fn () => $this->analytics()->buildSummary($measurements, $settings, $targets), [
'latest_measurement' => $measurements !== [] ? $measurements[array_key_last($measurements)] : null,
'baseline' => $settings,
'targets' => [],
'payouts' => [],
'miner_offers' => [],
]);
return [
'project' => $this->repository()->getProject($projectKey),
@@ -306,7 +334,11 @@ final class Router
'targets' => $targets,
'dashboards' => $dashboards,
'fx_snapshots' => $fxSnapshots,
'summary' => $this->analytics()->buildSummary($measurements, $settings, $targets),
'summary' => $summary,
'bootstrap_meta' => [
'degraded' => false,
'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2),
],
];
}
@@ -752,7 +784,7 @@ final class Router
$migratedRates = 0;
foreach ($legacyFetches as $legacyFetchId => $legacyFetch) {
$existing = $this->findMatchingFxRatesFetch($legacyFetch);
$existing = $this->findExistingFxFetchForLegacy($fxRepository, $legacyFetch);
if (is_array($existing) && !empty($existing['id'])) {
$fetchIdMap[$legacyFetchId] = (int) $existing['id'];
$reusedFetches++;
@@ -803,11 +835,6 @@ final class Router
);
$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;
@@ -873,7 +900,7 @@ final class Router
return $grouped;
}
private function findMatchingFxRatesFetch(array $legacyFetch): ?array
private function findExistingFxFetchForLegacy(object $fxRepository, array $legacyFetch): ?array
{
$baseCurrency = strtoupper(trim((string) ($legacyFetch['base_currency'] ?? '')));
$fetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? ''));
@@ -881,45 +908,12 @@ final class Router
return null;
}
$snapshot = $this->fx()->nearestSnapshot($baseCurrency, $fetchedAt, null, 1);
if (!is_array($snapshot)) {
if (!method_exists($fxRepository, 'findFetchByBaseAndFetchedAt')) {
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;
$existing = $fxRepository->findFetchByBaseAndFetchedAt($baseCurrency, $fetchedAt);
return is_array($existing) ? $existing : null;
}
private function resolveLegacyMeasurementFetchId(array $measurement, array $measurementRates, array $legacyFetches): ?int
@@ -1062,13 +1056,6 @@ final class Router
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);
@@ -1136,11 +1123,13 @@ final class Router
return $this->settings($projectKey);
}
private function measurements(string $projectKey, ?array $settingsOverride = null): array
private function measurements(string $projectKey, ?array $settingsOverride = null, bool $resolveFxReferences = true): array
{
$settings = $settingsOverride ?? $this->settings($projectKey);
$rows = $this->repository()->listMeasurements($projectKey, 500);
if ($resolveFxReferences) {
$rows = $this->ensureMeasurementFxReferences($projectKey, $rows, $settings);
}
return $this->analytics()->enrichMeasurements($rows, $settings);
}
@@ -2125,6 +2114,17 @@ final class Router
Http::json($payload, $statusCode);
}
private function releaseSessionLock(): void
{
if (PHP_SAPI === 'cli') {
return;
}
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
}
private function debugLatest(): array
{
$filePath = DebugState::latestFilePath();