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