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:37:41 +02:00
parent 5a154f896b
commit c444ece852
10 changed files with 436 additions and 152 deletions

View File

@@ -293,6 +293,7 @@ final class Router
$measurements = $this->measurements($projectKey);
$targets = $this->targets($projectKey);
$dashboards = $this->dashboards($projectKey);
$fxSnapshots = $this->measurementFxSnapshots($measurements);
return [
'project' => $this->repository()->getProject($projectKey),
@@ -300,6 +301,7 @@ final class Router
'measurements' => $measurements,
'targets' => $targets,
'dashboards' => $dashboards,
'fx_snapshots' => $fxSnapshots,
'summary' => $this->analytics()->buildSummary($measurements, $settings, $targets),
];
}
@@ -691,7 +693,42 @@ final class Router
private function fxHistory(): array
{
return $this->repository()->listFxRates(30);
if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'recent_fetches')) {
return [];
}
$fetches = module_fn('fx-rates', 'recent_fetches', 30);
if (!is_array($fetches)) {
return [];
}
$rows = [];
foreach ($fetches as $fetch) {
$fetchId = is_numeric($fetch['id'] ?? null) ? (int) $fetch['id'] : 0;
if ($fetchId <= 0) {
continue;
}
$snapshot = $this->fx()->snapshotByFetchId($fetchId, (string) ($fetch['base_currency'] ?? ''), null);
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
foreach ($rates as $currencyCode => $rate) {
if (!is_numeric($rate)) {
continue;
}
$rows[] = [
'fetch_id' => $fetchId,
'base_currency' => strtoupper((string) ($snapshot['base_currency'] ?? $fetch['base_currency'] ?? '')),
'target_currency' => strtoupper((string) $currencyCode),
'rate' => (float) $rate,
'rate_date' => (string) ($snapshot['rate_date'] ?? $fetch['rate_date'] ?? ''),
'provider' => (string) ($snapshot['provider'] ?? $fetch['provider'] ?? ''),
'fetched_at' => (string) ($snapshot['fetched_at'] ?? $fetch['fetched_at'] ?? ''),
];
}
}
return $rows;
}
private function settings(string $projectKey): array
@@ -729,7 +766,7 @@ final class Router
$base['payouts'] = $this->payouts($projectKey);
$base['miner_offers'] = $this->minerOffers($projectKey);
$base['purchased_miners'] = $this->purchasedMiners($projectKey);
$base['measurement_rates'] = $this->measurementRates($projectKey);
$base['measurement_rates'] = [];
return $base;
}
@@ -765,6 +802,7 @@ final class Router
{
$settings = $this->settings($projectKey);
$rows = $this->repository()->listMeasurements($projectKey, 500);
$rows = $this->ensureMeasurementFxReferences($projectKey, $rows);
return $this->analytics()->enrichMeasurements($rows, $settings);
}
@@ -785,6 +823,7 @@ final class Router
'ocr_raw_text' => $this->optionalString($input['ocr_raw_text'] ?? null, 65535),
'ocr_confidence' => $this->optionalDecimal($input['ocr_confidence'] ?? null),
'ocr_flags' => $this->optionalArray($input['ocr_flags'] ?? null),
'fx_fetch_id' => null,
];
if (($payload['price_per_coin'] === null) xor ($payload['price_currency'] === null)) {
@@ -792,8 +831,8 @@ final class Router
}
$this->syncCurrencyCatalogForMeasurement($payload);
$payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, true);
$created = $this->repository()->createMeasurement($projectKey, $payload);
$this->captureMeasurementRates($projectKey, $created);
$measurements = $this->measurements($projectKey);
return $measurements[array_key_last($measurements)];
}
@@ -823,11 +862,11 @@ final class Router
try {
$payload = $this->parseImportLine($trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey));
$this->syncCurrencyCatalogForMeasurement($payload);
$payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, false);
$result = $this->repository()->createMeasurementIfNotExists($projectKey, $payload);
if ($result === null) {
$duplicates++;
} else {
$this->captureMeasurementRates($projectKey, $result);
$imported++;
}
} catch (\Throwable $exception) {
@@ -954,7 +993,7 @@ final class Router
private function measurementRates(string $projectKey): array
{
return $this->repository()->listMeasurementRates($projectKey);
return [];
}
private function currencies(): array
@@ -1544,80 +1583,98 @@ final class Router
return $result;
}
private function captureMeasurementRates(string $projectKey, array $measurement): void
private function resolveMeasurementFxFetchId(string $projectKey, array $payload, bool $allowRefresh): ?int
{
$measurementId = (int) ($measurement['id'] ?? 0);
$price = is_numeric($measurement['price_per_coin'] ?? null) ? (float) $measurement['price_per_coin'] : null;
$priceCurrency = strtoupper(trim((string) ($measurement['price_currency'] ?? '')));
if ($measurementId <= 0) {
return;
}
$this->ensureFreshFxForMeasurement($projectKey, $priceCurrency !== '' ? $priceCurrency : 'USD');
$rates = [];
if ($price !== null && $price > 0 && $priceCurrency !== '') {
$rates[] = ['base_currency' => 'DOGE', 'quote_currency' => $priceCurrency, 'rate' => $price, 'provider' => 'measurement'];
$rates[] = ['base_currency' => $priceCurrency, 'quote_currency' => 'DOGE', 'rate' => 1 / $price, 'provider' => 'measurement'];
foreach (['USD', 'EUR'] as $fiatCurrency) {
if ($fiatCurrency === $priceCurrency) {
continue;
}
$convertedPrice = $this->priceForCurrency($price, $priceCurrency, $fiatCurrency);
if ($convertedPrice === null || $convertedPrice <= 0) {
continue;
}
$rates[] = ['base_currency' => 'DOGE', 'quote_currency' => $fiatCurrency, 'rate' => $convertedPrice, 'provider' => 'derived'];
$rates[] = ['base_currency' => $fiatCurrency, 'quote_currency' => 'DOGE', 'rate' => 1 / $convertedPrice, 'provider' => 'derived'];
}
} else {
foreach (['USD', 'EUR'] as $fiatCurrency) {
$fxPrice = $this->fx()->rate('DOGE', $fiatCurrency);
if ($fxPrice === null || $fxPrice <= 0) {
continue;
}
$rates[] = ['base_currency' => 'DOGE', 'quote_currency' => $fiatCurrency, 'rate' => $fxPrice, 'provider' => 'fx'];
$rates[] = ['base_currency' => $fiatCurrency, 'quote_currency' => 'DOGE', 'rate' => 1 / $fxPrice, 'provider' => 'fx'];
}
}
$eurUsd = $this->fx()->rate('EUR', 'USD');
if ($eurUsd !== null) {
$rates[] = ['base_currency' => 'EUR', 'quote_currency' => 'USD', 'rate' => $eurUsd, 'provider' => 'fx'];
$rates[] = ['base_currency' => 'USD', 'quote_currency' => 'EUR', 'rate' => 1 / $eurUsd, 'provider' => 'fx'];
}
if ($rates !== []) {
$this->repository()->replaceMeasurementRates($measurementId, $projectKey, $rates);
}
}
private function priceForCurrency(float $price, string $fromCurrency, string $toCurrency): ?float
{
$from = strtoupper(trim($fromCurrency));
$to = strtoupper(trim($toCurrency));
if ($from === $to) {
return $price;
}
$converted = $this->fx()->convert($price, $from, $to);
return is_numeric($converted) ? (float) $converted : null;
}
private function ensureFreshFxForMeasurement(string $projectKey, string $priceCurrency): void
{
$normalizedCurrency = strtoupper(trim($priceCurrency));
if ($normalizedCurrency === '') {
return;
}
$measuredAt = trim((string) ($payload['measured_at'] ?? ''));
$settings = $this->settings($projectKey);
$maxAgeHours = is_numeric($settings['fx_max_age_hours'] ?? null) ? (float) $settings['fx_max_age_hours'] : 3.0;
$this->fx()->ensureFreshLatestRates($maxAgeHours, 'USD');
if ($allowRefresh && $measuredAt !== '' && $this->isRecentTimestamp($measuredAt, $maxAgeHours)) {
$fresh = $this->fx()->ensureFreshLatestRates($maxAgeHours, 'USD');
if (is_array($fresh) && is_numeric($fresh['fetch_id'] ?? null)) {
return (int) $fresh['fetch_id'];
}
}
if ($measuredAt !== '') {
$nearest = $this->fx()->nearestSnapshot('USD', $measuredAt, null, null);
if (is_array($nearest) && is_numeric($nearest['id'] ?? null)) {
return (int) $nearest['id'];
}
}
$latest = $this->fx()->latestSnapshot('USD', null);
if (is_array($latest) && is_numeric($latest['id'] ?? null)) {
return (int) $latest['id'];
}
if ($allowRefresh) {
$fresh = $this->fx()->refreshLatestRates(null, 'USD');
if (is_array($fresh) && is_numeric($fresh['fetch_id'] ?? null)) {
return (int) $fresh['fetch_id'];
}
}
return null;
}
private function ensureMeasurementFxReferences(string $projectKey, array $rows): array
{
$resolved = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$fetchId = is_numeric($row['fx_fetch_id'] ?? null) ? (int) $row['fx_fetch_id'] : 0;
if ($fetchId <= 0) {
$resolvedFetchId = $this->resolveMeasurementFxFetchId($projectKey, $row, false);
if ($resolvedFetchId !== null) {
$row = $this->repository()->setMeasurementFxFetchId($projectKey, (int) ($row['id'] ?? 0), $resolvedFetchId) ?? array_merge($row, ['fx_fetch_id' => $resolvedFetchId]);
}
}
$resolved[] = $row;
}
return $resolved;
}
private function measurementFxSnapshots(array $measurements): array
{
$snapshots = [];
foreach ($measurements as $measurement) {
$fetchId = is_numeric($measurement['fx_fetch_id'] ?? null) ? (int) $measurement['fx_fetch_id'] : 0;
if ($fetchId <= 0 || isset($snapshots[$fetchId])) {
continue;
}
$snapshot = $this->fx()->snapshotByFetchId($fetchId, null, null);
if (!is_array($snapshot)) {
continue;
}
$snapshots[(string) $fetchId] = [
'id' => is_numeric($snapshot['id'] ?? null) ? (int) $snapshot['id'] : $fetchId,
'base_currency' => (string) ($snapshot['base_currency'] ?? ''),
'rate_date' => (string) ($snapshot['rate_date'] ?? ''),
'provider' => (string) ($snapshot['provider'] ?? ''),
'fetched_at' => (string) ($snapshot['fetched_at'] ?? ''),
'rates' => is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [],
];
}
return $snapshots;
}
private function isRecentTimestamp(string $timestamp, float $maxAgeHours): bool
{
$parsed = strtotime($timestamp);
if ($parsed === false) {
return false;
}
return abs(time() - $parsed) <= (int) round(max(0.25, $maxAgeHours) * 3600);
}
private function resolveOfferPurchaseCost(array $offer): float

View File

@@ -20,7 +20,12 @@ final class AnalyticsService
$baselineAt = (string) ($settings['baseline_measured_at'] ?? '');
$costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [];
$payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : [];
$measurementRates = is_array($settings['measurement_rates'] ?? null) ? $settings['measurement_rates'] : [];
$preferredPriceCurrencies = array_values(array_unique(array_filter([
strtoupper(trim((string) ($settings['report_currency'] ?? ''))),
'USD',
'EUR',
'DOGE',
])));
$baselineTs = $this->utcTimestamp($baselineAt);
$previous = null;
@@ -79,7 +84,7 @@ final class AnalyticsService
$latestPriceByCurrency[(string) $rawPriceCurrency] = $rawPrice;
}
$measurementDerivedPrices = $this->measurementDerivedPrices($measurementRates, (int) ($row['id'] ?? 0));
$measurementDerivedPrices = $this->measurementDerivedPrices($row, $preferredPriceCurrencies);
foreach ($measurementDerivedPrices as $derivedCurrency => $derivedPrice) {
$latestPriceByCurrency[$derivedCurrency] = $derivedPrice;
}
@@ -96,7 +101,7 @@ final class AnalyticsService
}
if ($price === null) {
foreach (['USD', 'EUR'] as $fallbackCurrency) {
$fxPrice = $this->convertAmount(1.0, 'DOGE', $fallbackCurrency);
$fxPrice = $this->convertAmount(1.0, 'DOGE', $fallbackCurrency, $row);
if ($fxPrice !== null && $fxPrice > 0) {
$latestPriceByCurrency[$fallbackCurrency] = $fxPrice;
}
@@ -107,7 +112,7 @@ final class AnalyticsService
}
}
$effectiveDailyCost = $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency);
$effectiveDailyCost = $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency, $row);
$currentValue = $price !== null ? $visibleCoinsTotal * $price : null;
$currentValueEffective = $price !== null ? $effectiveCoinsTotal * $price : null;
$theoreticalDailyRevenue = ($price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null;
@@ -148,6 +153,7 @@ final class AnalyticsService
'effective_price_per_coin' => $this->roundOrNull($price, 8),
'effective_price_currency' => $priceCurrency,
'price_is_fallback' => $rawPrice === null && $price !== null,
'price_quotes' => $measurementDerivedPrices,
'current_value' => $this->roundOrNull($currentValue, 8),
'current_value_effective' => $this->roundOrNull($currentValueEffective, 8),
'effective_daily_cost' => $this->roundOrNull($effectiveDailyCost, 8),
@@ -183,10 +189,18 @@ final class AnalyticsService
$latest = $measurements[array_key_last($measurements)];
$latestPriceByCurrency = [];
foreach ($measurements as $measurement) {
if ($measurement['price_per_coin'] !== null && $measurement['price_currency'] !== null) {
$latestPriceByCurrency[(string) $measurement['price_currency']] = (float) $measurement['price_per_coin'];
$quotes = is_array($measurement['price_quotes'] ?? null) ? $measurement['price_quotes'] : [];
foreach ($quotes as $currency => $price) {
$normalizedCurrency = strtoupper(trim((string) $currency));
if ($normalizedCurrency !== '' && is_numeric($price)) {
$latestPriceByCurrency[$normalizedCurrency] = (float) $price;
}
}
}
$latestEffectiveCurrency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '')));
if ($latestEffectiveCurrency !== '' && is_numeric($latest['effective_price_per_coin'] ?? null)) {
$latestPriceByCurrency[$latestEffectiveCurrency] = (float) $latest['effective_price_per_coin'];
}
$payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : [];
$purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : [];
@@ -221,7 +235,7 @@ final class AnalyticsService
: (is_numeric($linkedOffer['effective_price_amount'] ?? null) ? (float) $linkedOffer['effective_price_amount'] : $targetAmount);
}
$price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency);
$price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency, $latest);
$requiredDoge = ($price && $targetAmount !== null) ? $targetAmount / $price : null;
$remainingDoge = $requiredDoge !== null ? $requiredDoge - (float) ($latest['coins_total_effective'] ?? $latest['coins_total']) : null;
$remainingDays = (
@@ -264,7 +278,8 @@ final class AnalyticsService
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
$purchasedMiners,
$this->utcTimestamp((string) ($latest['measured_at'] ?? '')),
$latestCurrency
$latestCurrency,
$latest
)
: null;
$currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null;
@@ -399,7 +414,7 @@ final class AnalyticsService
return $value === null ? null : round($value, $precision);
}
private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency): ?float
private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency, ?array $fxContext = null): ?float
{
if ($currency === null) {
return null;
@@ -429,7 +444,8 @@ final class AnalyticsService
$convertedDailyCost = $this->convertAmount(
$planDailyCost,
(string) ($plan['currency'] ?? ''),
$currency
$currency,
$fxContext
);
if ($convertedDailyCost === null) {
continue;
@@ -442,10 +458,10 @@ final class AnalyticsService
return $matched ? $dailyTotal : null;
}
private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency): ?float
private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency, ?array $fxContext = null): ?float
{
foreach ($latestPriceByCurrency as $sourceCurrency => $price) {
$converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency);
$converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency, $fxContext);
if ($converted !== null) {
return $converted;
}
@@ -474,33 +490,32 @@ final class AnalyticsService
return is_string($derivedFirst) ? $derivedFirst : null;
}
private function measurementDerivedPrices(array $measurementRates, int $measurementId): array
private function measurementDerivedPrices(array $measurement, array $targetCurrencies): array
{
if ($measurementId <= 0 || $measurementRates === []) {
$rawPrice = is_numeric($measurement['price_per_coin'] ?? null) ? (float) $measurement['price_per_coin'] : null;
$rawCurrency = strtoupper(trim((string) ($measurement['price_currency'] ?? '')));
if ($rawPrice === null || $rawPrice <= 0 || $rawCurrency === '') {
return [];
}
$prices = [];
foreach ($measurementRates as $row) {
if ((int) ($row['measurement_id'] ?? 0) !== $measurementId) {
$prices = [$rawCurrency => $rawPrice];
foreach ($targetCurrencies as $targetCurrency) {
$targetCurrency = strtoupper(trim((string) $targetCurrency));
if ($targetCurrency === '' || isset($prices[$targetCurrency])) {
continue;
}
$baseCurrency = strtoupper(trim((string) ($row['base_currency'] ?? '')));
$quoteCurrency = strtoupper(trim((string) ($row['target_currency'] ?? $row['quote_currency'] ?? '')));
$rate = is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null;
if ($baseCurrency !== 'DOGE' || $quoteCurrency === '' || $rate === null || $rate <= 0) {
continue;
$converted = $this->convertAmount($rawPrice, $rawCurrency, $targetCurrency, $measurement);
if ($converted !== null && $converted > 0) {
$prices[$targetCurrency] = $converted;
}
$prices[$quoteCurrency] = $rate;
}
return $prices;
}
private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency): ?float
private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?array $fxContext = null): ?float
{
if ($amount === null || $fromCurrency === null || $toCurrency === null) {
return null;
@@ -520,7 +535,12 @@ final class AnalyticsService
return null;
}
return $this->fx->convert($amount, $from, $to);
$fetchId = isset($fxContext['fx_fetch_id']) && is_numeric($fxContext['fx_fetch_id'])
? (int) $fxContext['fx_fetch_id']
: null;
$at = is_string($fxContext['measured_at'] ?? null) ? (string) $fxContext['measured_at'] : null;
return $this->fx->convertAt($amount, $from, $to, $at, null, $fetchId);
}
private function totalHashrateMh(array $entries): float
@@ -541,7 +561,7 @@ final class AnalyticsService
return $total;
}
private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null): ?float
private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null, ?array $fxContext = null): ?float
{
$target = strtoupper(trim($targetCurrency));
if ($target === '') {
@@ -564,7 +584,7 @@ final class AnalyticsService
continue;
}
$converted = $this->convertAmount($amount, $currency, $target);
$converted = $this->convertAmount($amount, $currency, $target, $fxContext);
if ($converted === null) {
continue;
}
@@ -576,7 +596,7 @@ final class AnalyticsService
return $matched ? $total : null;
}
private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency): ?float
private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency, ?array $fxContext = null): ?float
{
$target = strtoupper(trim($targetCurrency));
if ($target === '') {
@@ -586,7 +606,7 @@ final class AnalyticsService
$total = 0.0;
$matched = false;
$purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs);
$purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs, $fxContext);
if ($purchasedTotal !== null) {
$matched = true;
$total += $purchasedTotal;
@@ -616,7 +636,7 @@ final class AnalyticsService
continue;
}
$converted = $this->convertAmount($amount, $currency, $target);
$converted = $this->convertAmount($amount, $currency, $target, $fxContext);
if ($converted === null) {
continue;
}
@@ -688,7 +708,7 @@ final class AnalyticsService
: $basePriceCurrency;
$effectivePriceAmount = $basePriceAmount;
if ($basePriceAmount !== null && $basePriceAmount > 0 && $basePriceCurrency !== '' && $effectivePriceCurrency !== '' && strtoupper($basePriceCurrency) !== strtoupper($effectivePriceCurrency)) {
$convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency);
$convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency, $latest);
if ($convertedReference !== null && $convertedReference > 0) {
$effectivePriceAmount = $convertedReference;
}
@@ -701,7 +721,7 @@ final class AnalyticsService
$expectedDogePerDay = ((float) $latest['doge_per_day_interval'] / $currentHashrateMh) * $offerHashrateMh;
}
$offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency)) : null;
$offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency, $latest)) : null;
$expectedDailyRevenue = ($expectedDogePerDay !== null && $offerCurrencyPrice !== null)
? $expectedDogePerDay * $offerCurrencyPrice
: null;
@@ -752,7 +772,7 @@ final class AnalyticsService
$offerPriceAmount !== null &&
$offerPriceCurrency !== '' &&
$latestCurrency !== ''
) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency) : null;
) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency, $latest) : null;
$scenarioDogePerDay = (
$currentDogePerDay !== null &&
$expectedDogePerDay !== null
@@ -864,7 +884,7 @@ final class AnalyticsService
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
if ($runtimeMonths > 0 && is_numeric($plan['total_cost_amount'] ?? null)) {
$runtimeDays = $runtimeMonths * 30.4375;
$dailyCost = $this->convertAmount((float) $plan['total_cost_amount'] / $runtimeDays, (string) ($plan['currency'] ?? ''), $currency);
$dailyCost = $this->convertAmount((float) $plan['total_cost_amount'] / $runtimeDays, (string) ($plan['currency'] ?? ''), $currency, $latest);
if ($dailyCost !== null) {
$cost += $dailyCost;
}
@@ -882,7 +902,7 @@ final class AnalyticsService
$runtimeMonths = (int) ($miner['runtime_months'] ?? 0);
if ($runtimeMonths > 0 && is_numeric($miner['total_cost_amount'] ?? null)) {
$runtimeDays = $runtimeMonths * 30.4375;
$dailyCost = $this->convertAmount((float) $miner['total_cost_amount'] / $runtimeDays, (string) ($miner['currency'] ?? ''), $currency);
$dailyCost = $this->convertAmount((float) $miner['total_cost_amount'] / $runtimeDays, (string) ($miner['currency'] ?? ''), $currency, $latest);
if ($dailyCost !== null) {
$cost += $dailyCost;
}

View File

@@ -44,30 +44,46 @@ final class FxService
public function convert(?float $amount, ?string $from, ?string $to): ?float
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'convert')) {
$converted = $shared->convert($amount, $from, $to, null, null);
return is_numeric($converted) ? (float) $converted : null;
}
return $this->convertAt($amount, $from, $to, null, null, null);
}
public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
{
if ($amount === null || $from === null || $to === null) {
return null;
}
$rate = $this->rate($from, $to);
$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 (is_array($snapshot)) {
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
if ($resolved !== null) {
return $amount * $resolved;
}
}
}
if ($shared !== null && method_exists($shared, 'convert')) {
$converted = $shared->convert($amount, $from, $to, $at, $windowMinutes);
return is_numeric($converted) ? (float) $converted : null;
}
$rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId);
return $rate === null ? null : $amount * $rate;
}
public function rate(?string $from, ?string $to): ?float
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'findRate')) {
$resolved = $shared->findRate($from, $to, null, null);
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
}
return $this->rateAt($from, $to, null, null, null);
}
public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
{
$base = strtoupper(trim((string) $from));
$target = strtoupper(trim((string) $to));
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
if ($base === '' || $target === '') {
return null;
@@ -77,7 +93,22 @@ final class FxService
return 1.0;
}
$cacheKey = $base . ':' . $target;
$shared = $this->sharedFxService();
if ($shared !== null && $normalizedFetchId !== null && method_exists($shared, 'snapshotByFetchId')) {
$snapshot = $shared->snapshotByFetchId($normalizedFetchId, $base, [$target]);
if (is_array($snapshot)) {
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
if ($resolved !== null) {
return $resolved;
}
}
}
if ($shared !== null && method_exists($shared, 'findRate')) {
$resolved = $shared->findRate($from, $to, $at, $windowMinutes);
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
}
$cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]);
if (array_key_exists($cacheKey, $this->memoryCache)) {
return $this->memoryCache[$cacheKey];
}
@@ -107,6 +138,43 @@ final class FxService
return $rate;
}
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
{
if ($fetchId <= 0) {
return null;
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
$snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols);
return is_array($snapshot) ? $snapshot : null;
}
return null;
}
public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'snapshot')) {
$snapshot = $shared->snapshot($baseCurrency, null, $symbols, null);
return is_array($snapshot) ? $snapshot : null;
}
return null;
}
public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
$snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes);
return is_array($snapshot) ? $snapshot : null;
}
return null;
}
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
{
$shared = $this->sharedFxService();
@@ -807,4 +875,28 @@ final class FxService
return null;
}
}
private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float
{
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
if ($base === '' || $from === '' || $to === '') {
return null;
}
if ($base === $from && is_numeric($rates[$to] ?? null)) {
return (float) $rates[$to];
}
if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
return 1 / (float) $rates[$from];
}
if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
return (float) $rates[$to] / (float) $rates[$from];
}
return null;
}
}

View File

@@ -430,6 +430,7 @@ final class MiningRepository
'project_key' => $projectKey,
'measured_at' => $payload['measured_at'] ?? null,
'price_currency' => $payload['price_currency'] ?? null,
'fx_fetch_id' => $payload['fx_fetch_id'] ?? null,
]);
$params = [
'project_key' => $projectKey,
@@ -438,6 +439,7 @@ final class MiningRepository
'coins_total' => $payload['coins_total'],
'price_per_coin' => $payload['price_per_coin'],
'price_currency' => $payload['price_currency'],
'fx_fetch_id' => $payload['fx_fetch_id'] ?? null,
'note' => $payload['note'],
'source' => $payload['source'],
'image_path' => $payload['image_path'],
@@ -449,10 +451,10 @@ final class MiningRepository
if ($this->driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('measurements') . ' (
project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, note,
project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note,
source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
) VALUES (
:project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :note,
:project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note,
:source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb)
)
RETURNING *'
@@ -465,10 +467,10 @@ final class MiningRepository
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('measurements') . ' (
project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, note,
project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note,
source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
) VALUES (
:project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :note,
:project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note,
:source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags
)'
);
@@ -497,6 +499,38 @@ final class MiningRepository
}
}
public function setMeasurementFxFetchId(string $projectKey, int $measurementId, ?int $fxFetchId): ?array
{
if ($measurementId <= 0) {
return null;
}
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->table('measurements') . '
SET fx_fetch_id = :fx_fetch_id
WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id'
);
$stmt->execute([
'fx_fetch_id' => $fxFetchId,
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'id' => $measurementId,
]);
$fetch = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('measurements') . '
WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1'
);
$fetch->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'id' => $measurementId,
]);
$row = $fetch->fetch();
return is_array($row) ? $this->normalizeRow($row) : null;
}
public function replaceMeasurementRates(int $measurementId, string $projectKey, array $rates): void
{
$delete = $this->pdo->prepare('DELETE FROM ' . $this->table('measurement_rates') . ' WHERE measurement_id = :measurement_id AND owner_sub = :owner_sub');

View File

@@ -184,6 +184,10 @@ final class SchemaManager
$this->upgradeSettingsPreferredCurrenciesColumn();
$applied[] = 'settings_preferences';
}
if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) {
$this->ensureMeasurementFxReferenceColumn();
$applied[] = 'measurement_fx_reference';
}
if (!$this->tableExists($this->prefix . 'measurement_rates')) {
$this->ensureMeasurementRatesTable();
@@ -298,6 +302,10 @@ final class SchemaManager
$this->upgradeSettingsPreferredCurrenciesColumn();
$applied[] = 'settings_preferences';
}
if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) {
$this->ensureMeasurementFxReferenceColumn();
$applied[] = 'measurement_fx_reference';
}
if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) {
$this->ensureFxRatesTable();
@@ -404,6 +412,40 @@ final class SchemaManager
$this->ensureLegacyMinerOfferImportColumns();
}
private function ensureMeasurementFxReferenceColumn(): void
{
$table = $this->prefix . 'measurements';
if (!$this->tableExists($table)) {
return;
}
$statements = $this->driver === 'pgsql'
? [
'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS fx_fetch_id BIGINT',
'CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch ON ' . $table . ' (fx_fetch_id)',
]
: [
'ALTER TABLE `' . $table . '` ADD COLUMN fx_fetch_id BIGINT UNSIGNED NULL',
'ALTER TABLE `' . $table . '` ADD INDEX idx_miningcheck_measurements_fx_fetch (fx_fetch_id)',
];
foreach ($statements as $statement) {
try {
$this->executeUpgradeStatements([$statement], 'Messpunkt-FX-Referenz konnte nicht angelegt werden.');
} catch (\Throwable $exception) {
$message = strtolower($exception->getMessage());
if (
($this->driver === 'mysql' && (str_contains($message, 'duplicate column') || str_contains($message, 'duplicate key name'))) ||
($this->driver === 'pgsql' && str_contains($message, 'already exists'))
) {
continue;
}
throw $exception;
}
}
}
private function ensureLegacyMinerOfferImportColumns(): void
{
$table = $this->prefix . 'miner_offers';
@@ -579,6 +621,10 @@ final class SchemaManager
}
}
if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) {
$upgrades[] = 'measurement_fx_reference';
}
if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) {
$upgrades[] = 'fx_rates_table';
}