asdasd
This commit is contained in:
@@ -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() {
|
async function importSqlFile() {
|
||||||
if (!sqlImportFile) {
|
if (!sqlImportFile) {
|
||||||
setError('Bitte zuerst eine SQL-Datei auswaehlen.');
|
setError('Bitte zuerst eine SQL-Datei auswaehlen.');
|
||||||
@@ -2881,6 +2911,13 @@
|
|||||||
onClick: importOldData,
|
onClick: importOldData,
|
||||||
disabled: saving,
|
disabled: saving,
|
||||||
}, saving ? 'Importiert …' : 'Alte Daten importieren'),
|
}, 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('div', { key: 'sql-import', className: 'mc-form' }, [
|
||||||
h('label', { className: 'mc-field' }, [
|
h('label', { className: 'mc-field' }, [
|
||||||
h('span', { className: 'mc-field-label' }, 'SQL-Datei importieren'),
|
h('span', { className: 'mc-field-label' }, 'SQL-Datei importieren'),
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ modules/mining-checker/
|
|||||||
- `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh`
|
- `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh`
|
||||||
- `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh`
|
- `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh`
|
||||||
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
|
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
|
||||||
|
- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate`
|
||||||
|
|
||||||
## Integration
|
## 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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ final class Router
|
|||||||
$this->respond(['data' => $this->fxHistory()]);
|
$this->respond(['data' => $this->fxHistory()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($resource === 'legacy-fx-migrate' && $method === 'POST') {
|
||||||
|
$this->respond(['data' => $this->migrateLegacyFxRates($projectKey)], 201);
|
||||||
|
}
|
||||||
|
|
||||||
if ($resource === 'bootstrap' && $method === 'GET') {
|
if ($resource === 'bootstrap' && $method === 'GET') {
|
||||||
$this->respond(['data' => $this->bootstrap($projectKey)]);
|
$this->respond(['data' => $this->bootstrap($projectKey)]);
|
||||||
}
|
}
|
||||||
@@ -731,6 +735,340 @@ final class Router
|
|||||||
return $rows;
|
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
|
private function settings(string $projectKey): array
|
||||||
{
|
{
|
||||||
$settings = $this->repository()->getSettings($projectKey);
|
$settings = $this->repository()->getSettings($projectKey);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ final class FxService
|
|||||||
private int $cacheTtl;
|
private int $cacheTtl;
|
||||||
private bool $autoFetchOnMiss;
|
private bool $autoFetchOnMiss;
|
||||||
private array $memoryCache = [];
|
private array $memoryCache = [];
|
||||||
|
private array $snapshotCache = [];
|
||||||
private ?DebugTrace $debug;
|
private ?DebugTrace $debug;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -55,8 +56,8 @@ final class FxService
|
|||||||
|
|
||||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||||
$shared = $this->sharedFxService();
|
$shared = $this->sharedFxService();
|
||||||
if ($shared !== null && $normalizedFetchId !== null && method_exists($shared, 'snapshotByFetchId')) {
|
if ($shared !== null && $normalizedFetchId !== null) {
|
||||||
$snapshot = $shared->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]);
|
$snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]);
|
||||||
if (is_array($snapshot)) {
|
if (is_array($snapshot)) {
|
||||||
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
|
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
|
||||||
if ($resolved !== null) {
|
if ($resolved !== null) {
|
||||||
@@ -94,8 +95,8 @@ final class FxService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$shared = $this->sharedFxService();
|
$shared = $this->sharedFxService();
|
||||||
if ($shared !== null && $normalizedFetchId !== null && method_exists($shared, 'snapshotByFetchId')) {
|
if ($shared !== null && $normalizedFetchId !== null) {
|
||||||
$snapshot = $shared->snapshotByFetchId($normalizedFetchId, $base, [$target]);
|
$snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]);
|
||||||
if (is_array($snapshot)) {
|
if (is_array($snapshot)) {
|
||||||
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
|
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
|
||||||
if ($resolved !== null) {
|
if ($resolved !== null) {
|
||||||
@@ -144,35 +145,62 @@ final class FxService
|
|||||||
return null;
|
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();
|
$shared = $this->sharedFxService();
|
||||||
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
|
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
|
||||||
$snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols);
|
$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
|
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();
|
$shared = $this->sharedFxService();
|
||||||
if ($shared !== null && method_exists($shared, 'snapshot')) {
|
if ($shared !== null && method_exists($shared, 'snapshot')) {
|
||||||
$snapshot = $shared->snapshot($baseCurrency, null, $symbols, null);
|
$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
|
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();
|
$shared = $this->sharedFxService();
|
||||||
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
|
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
|
||||||
$snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes);
|
$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
|
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
|
||||||
@@ -899,4 +927,23 @@ final class FxService
|
|||||||
|
|
||||||
return null;
|
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