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

This commit is contained in:
2026-04-29 02:58:05 +02:00
parent 1ba8b550f6
commit 8bab6f9e26
6 changed files with 167 additions and 5 deletions

View File

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

View File

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

View File

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