asdasd
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user