Nexus upgrade design and refresh
This commit is contained in:
1747
modules/mining-checker/src/Api/Router.php
Normal file
1747
modules/mining-checker/src/Api/Router.php
Normal file
File diff suppressed because it is too large
Load Diff
937
modules/mining-checker/src/Domain/AnalyticsService.php
Normal file
937
modules/mining-checker/src/Domain/AnalyticsService.php
Normal file
@@ -0,0 +1,937 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
|
||||
final class AnalyticsService
|
||||
{
|
||||
private ?FxService $fx;
|
||||
|
||||
public function __construct(?FxService $fx = null)
|
||||
{
|
||||
$this->fx = $fx;
|
||||
}
|
||||
|
||||
public function enrichMeasurements(array $measurements, array $settings): array
|
||||
{
|
||||
$baselineCoins = (float) ($settings['baseline_coins_total'] ?? 0.0);
|
||||
$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'] : [];
|
||||
|
||||
$baselineTs = $this->utcTimestamp($baselineAt);
|
||||
$previous = null;
|
||||
$previousIntervalRate = null;
|
||||
$result = [];
|
||||
$payoutIndex = 0;
|
||||
$cumulativePayouts = 0.0;
|
||||
$latestPriceByCurrency = [];
|
||||
|
||||
foreach ($measurements as $row) {
|
||||
$measuredTs = $this->utcTimestamp((string) $row['measured_at']);
|
||||
while (isset($payouts[$payoutIndex])) {
|
||||
$payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? ''));
|
||||
if ($payoutTs <= 0 || $payoutTs > $measuredTs) {
|
||||
break;
|
||||
}
|
||||
|
||||
$cumulativePayouts += (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0);
|
||||
$payoutIndex++;
|
||||
}
|
||||
|
||||
$visibleCoinsTotal = (float) $row['coins_total'];
|
||||
$effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts;
|
||||
$growth = $effectiveCoinsTotal - $baselineCoins;
|
||||
$hoursSinceBaseline = $baselineTs > 0 && $measuredTs > $baselineTs ? ($measuredTs - $baselineTs) / 3600 : 0.0;
|
||||
$perHourSinceBaseline = $hoursSinceBaseline > 0 ? $growth / $hoursSinceBaseline : null;
|
||||
$perDaySinceBaseline = $perHourSinceBaseline !== null ? $perHourSinceBaseline * 24 : null;
|
||||
|
||||
$intervalHours = null;
|
||||
$intervalGrowth = null;
|
||||
$perHourInterval = null;
|
||||
$perDayInterval = null;
|
||||
|
||||
if (is_array($previous)) {
|
||||
$intervalHours = max(0.0, ($measuredTs - ($this->utcTimestamp((string) $previous['measured_at']) ?: $measuredTs)) / 3600);
|
||||
$intervalGrowth = $effectiveCoinsTotal - (float) ($previous['coins_total_effective'] ?? $previous['coins_total']);
|
||||
$perHourInterval = $intervalHours > 0 ? $intervalGrowth / $intervalHours : null;
|
||||
$perDayInterval = $perHourInterval !== null ? $perHourInterval * 24 : null;
|
||||
}
|
||||
|
||||
$trendLabel = 'stabil';
|
||||
if ($perHourInterval !== null && $previousIntervalRate !== null) {
|
||||
$delta = $perHourInterval - $previousIntervalRate;
|
||||
$threshold = max(abs($previousIntervalRate) * 0.05, 0.01);
|
||||
if ($delta > $threshold) {
|
||||
$trendLabel = 'steigend';
|
||||
} elseif ($delta < -$threshold) {
|
||||
$trendLabel = 'fallend';
|
||||
}
|
||||
}
|
||||
|
||||
$rawPrice = isset($row['price_per_coin']) && $row['price_per_coin'] !== null ? (float) $row['price_per_coin'] : null;
|
||||
$rawPriceCurrency = $row['price_currency'] ?: null;
|
||||
|
||||
if ($rawPrice !== null && $rawPriceCurrency !== null) {
|
||||
$latestPriceByCurrency[(string) $rawPriceCurrency] = $rawPrice;
|
||||
}
|
||||
|
||||
$measurementDerivedPrices = $this->measurementDerivedPrices($measurementRates, (int) ($row['id'] ?? 0));
|
||||
foreach ($measurementDerivedPrices as $derivedCurrency => $derivedPrice) {
|
||||
$latestPriceByCurrency[$derivedCurrency] = $derivedPrice;
|
||||
}
|
||||
|
||||
$priceCurrency = $rawPriceCurrency !== null
|
||||
? (string) $rawPriceCurrency
|
||||
: $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices);
|
||||
$price = $rawPrice;
|
||||
if ($price === null && $priceCurrency !== null && isset($measurementDerivedPrices[$priceCurrency])) {
|
||||
$price = (float) $measurementDerivedPrices[$priceCurrency];
|
||||
}
|
||||
if ($price === null && $priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) {
|
||||
$price = (float) $latestPriceByCurrency[$priceCurrency];
|
||||
}
|
||||
if ($price === null) {
|
||||
foreach (['USD', 'EUR'] as $fallbackCurrency) {
|
||||
$fxPrice = $this->convertAmount(1.0, 'DOGE', $fallbackCurrency);
|
||||
if ($fxPrice !== null && $fxPrice > 0) {
|
||||
$latestPriceByCurrency[$fallbackCurrency] = $fxPrice;
|
||||
}
|
||||
}
|
||||
$priceCurrency = $priceCurrency ?? $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices);
|
||||
if ($priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) {
|
||||
$price = (float) $latestPriceByCurrency[$priceCurrency];
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveDailyCost = $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency);
|
||||
$currentValue = $price !== null ? $visibleCoinsTotal * $price : null;
|
||||
$currentValueEffective = $price !== null ? $effectiveCoinsTotal * $price : null;
|
||||
$theoreticalDailyRevenue = ($price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null;
|
||||
$theoreticalDailyProfit = (
|
||||
$theoreticalDailyRevenue !== null &&
|
||||
$effectiveDailyCost !== null
|
||||
) ? $theoreticalDailyRevenue - $effectiveDailyCost : null;
|
||||
$breakEvenPricePerCoin = (
|
||||
$effectiveDailyCost !== null &&
|
||||
$perDayInterval !== null &&
|
||||
$perDayInterval > 0
|
||||
) ? $effectiveDailyCost / $perDayInterval : null;
|
||||
$profitMarginPercent = (
|
||||
$theoreticalDailyRevenue !== null &&
|
||||
$theoreticalDailyRevenue > 0 &&
|
||||
$theoreticalDailyProfit !== null
|
||||
) ? ($theoreticalDailyProfit / $theoreticalDailyRevenue) * 100 : null;
|
||||
|
||||
$normalizedFlags = $row['ocr_flags'];
|
||||
if (is_string($normalizedFlags) && $normalizedFlags !== '') {
|
||||
$decoded = json_decode($normalizedFlags, true);
|
||||
$normalizedFlags = is_array($decoded) ? $decoded : [$normalizedFlags];
|
||||
}
|
||||
|
||||
$result[] = array_merge($row, [
|
||||
'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6),
|
||||
'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6),
|
||||
'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6),
|
||||
'growth_since_baseline' => $this->roundOrNull($growth, 6),
|
||||
'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4),
|
||||
'doge_per_hour_since_baseline' => $this->roundOrNull($perHourSinceBaseline, 6),
|
||||
'doge_per_day_since_baseline' => $this->roundOrNull($perDaySinceBaseline, 6),
|
||||
'interval_hours' => $this->roundOrNull($intervalHours, 4),
|
||||
'interval_growth' => $this->roundOrNull($intervalGrowth, 6),
|
||||
'doge_per_hour_interval' => $this->roundOrNull($perHourInterval, 6),
|
||||
'doge_per_day_interval' => $this->roundOrNull($perDayInterval, 6),
|
||||
'trend_label' => $trendLabel,
|
||||
'effective_price_per_coin' => $this->roundOrNull($price, 8),
|
||||
'effective_price_currency' => $priceCurrency,
|
||||
'price_is_fallback' => $rawPrice === null && $price !== null,
|
||||
'current_value' => $this->roundOrNull($currentValue, 8),
|
||||
'current_value_effective' => $this->roundOrNull($currentValueEffective, 8),
|
||||
'effective_daily_cost' => $this->roundOrNull($effectiveDailyCost, 8),
|
||||
'theoretical_daily_revenue' => $this->roundOrNull($theoreticalDailyRevenue, 8),
|
||||
'theoretical_daily_profit' => $this->roundOrNull($theoreticalDailyProfit, 8),
|
||||
'break_even_price_per_coin' => $this->roundOrNull($breakEvenPricePerCoin, 8),
|
||||
'profit_margin_percent' => $this->roundOrNull($profitMarginPercent, 4),
|
||||
'measured_date' => substr((string) $row['measured_at'], 0, 10),
|
||||
'ocr_flags' => is_array($normalizedFlags) ? $normalizedFlags : [],
|
||||
]);
|
||||
|
||||
if ($perHourInterval !== null) {
|
||||
$previousIntervalRate = $perHourInterval;
|
||||
}
|
||||
$previous = $row;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function buildSummary(array $measurements, array $settings, array $targets): array
|
||||
{
|
||||
if ($measurements === []) {
|
||||
return [
|
||||
'latest_measurement' => null,
|
||||
'baseline' => $settings,
|
||||
'targets' => [],
|
||||
'payouts' => [],
|
||||
'miner_offers' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$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'];
|
||||
}
|
||||
}
|
||||
|
||||
$payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : [];
|
||||
$purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : [];
|
||||
$minerOffers = is_array($settings['miner_offers'] ?? null) ? $settings['miner_offers'] : [];
|
||||
$currentHashrateMh = $this->totalHashrateMh(array_merge(
|
||||
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||
$purchasedMiners
|
||||
));
|
||||
$offerSummary = [];
|
||||
foreach ($minerOffers as $offer) {
|
||||
$offerSummary[] = $this->evaluateMinerOffer($offer, $latest, $latestPriceByCurrency, $currentHashrateMh, $settings);
|
||||
}
|
||||
|
||||
$targetSummary = [];
|
||||
foreach ($targets as $target) {
|
||||
$currency = (string) $target['currency'];
|
||||
$targetAmount = is_numeric($target['target_amount_fiat'] ?? null) ? (float) $target['target_amount_fiat'] : null;
|
||||
$linkedOffer = null;
|
||||
if (is_numeric($target['miner_offer_id'] ?? null)) {
|
||||
foreach ($offerSummary as $offer) {
|
||||
if ((int) ($offer['id'] ?? 0) === (int) $target['miner_offer_id']) {
|
||||
$linkedOffer = $offer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($linkedOffer)) {
|
||||
$currency = (string) ($linkedOffer['reference_price_currency'] ?? $linkedOffer['effective_price_currency'] ?? $currency);
|
||||
$targetAmount = is_numeric($linkedOffer['reference_price_amount'] ?? null)
|
||||
? (float) $linkedOffer['reference_price_amount']
|
||||
: (is_numeric($linkedOffer['effective_price_amount'] ?? null) ? (float) $linkedOffer['effective_price_amount'] : $targetAmount);
|
||||
}
|
||||
|
||||
$price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency);
|
||||
$requiredDoge = ($price && $targetAmount !== null) ? $targetAmount / $price : null;
|
||||
$remainingDoge = $requiredDoge !== null ? $requiredDoge - (float) ($latest['coins_total_effective'] ?? $latest['coins_total']) : null;
|
||||
$remainingDays = (
|
||||
$remainingDoge !== null &&
|
||||
$latest['doge_per_day_interval'] !== null &&
|
||||
(float) $latest['doge_per_day_interval'] > 0
|
||||
) ? $remainingDoge / (float) $latest['doge_per_day_interval'] : null;
|
||||
$targetEtaAt = null;
|
||||
if ($remainingDays !== null) {
|
||||
if ($remainingDays <= 0) {
|
||||
$targetEtaAt = (string) ($latest['measured_at'] ?? '');
|
||||
} elseif (!empty($latest['measured_at'])) {
|
||||
try {
|
||||
$targetEtaAt = $this->formatUtcTimestamp(
|
||||
$this->utcTimestamp((string) $latest['measured_at']) + (int) round($remainingDays * 86400)
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$targetEtaAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$targetSummary[] = array_merge($target, [
|
||||
'effective_target_amount_fiat' => $this->roundOrNull($targetAmount, 2),
|
||||
'effective_currency' => $currency,
|
||||
'linked_offer_id' => $linkedOffer['id'] ?? ($target['miner_offer_id'] ?? null),
|
||||
'linked_offer_label' => $linkedOffer['label'] ?? null,
|
||||
'latest_price_for_currency' => $price,
|
||||
'required_doge' => $this->roundOrNull($requiredDoge, 6),
|
||||
'remaining_doge' => $this->roundOrNull($remainingDoge, 6),
|
||||
'remaining_days' => $this->roundOrNull($remainingDays, 4),
|
||||
'target_eta_at' => $targetEtaAt,
|
||||
'status' => $remainingDoge !== null && $remainingDoge <= 0 ? 'reached' : 'open',
|
||||
]);
|
||||
}
|
||||
|
||||
$latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '');
|
||||
$investedCapital = $latestCurrency !== ''
|
||||
? $this->totalInvestmentBasis(
|
||||
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||
$purchasedMiners,
|
||||
$this->utcTimestamp((string) ($latest['measured_at'] ?? '')),
|
||||
$latestCurrency
|
||||
)
|
||||
: null;
|
||||
$currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null;
|
||||
$breakEvenRemainingAmount = $investedCapital;
|
||||
$breakEvenDaysOverall = (
|
||||
$investedCapital !== null &&
|
||||
$currentDailyRevenue !== null &&
|
||||
$currentDailyRevenue > 0
|
||||
) ? ($investedCapital / $currentDailyRevenue) : null;
|
||||
$latestSummary = array_merge($latest, [
|
||||
'invested_capital' => $this->roundOrNull($investedCapital, 8),
|
||||
'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8),
|
||||
'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4),
|
||||
]);
|
||||
$currentProjection = $this->projectPerformance(
|
||||
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||
$purchasedMiners,
|
||||
$latestSummary,
|
||||
730
|
||||
);
|
||||
$latestSummary = array_merge($latestSummary, [
|
||||
'projection_days' => $currentProjection['days'],
|
||||
'projection_two_year_revenue' => $this->roundOrNull($currentProjection['revenue'], 8),
|
||||
'projection_two_year_cost' => $this->roundOrNull($currentProjection['cost'], 8),
|
||||
'projection_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8),
|
||||
]);
|
||||
$offerSummary = array_map(
|
||||
fn (array $offer): array => $this->enrichOfferScenario(
|
||||
$offer,
|
||||
$latestSummary,
|
||||
$currentHashrateMh,
|
||||
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||
$purchasedMiners
|
||||
),
|
||||
$offerSummary
|
||||
);
|
||||
|
||||
return [
|
||||
'latest_measurement' => $latestSummary,
|
||||
'baseline' => $settings,
|
||||
'targets' => $targetSummary,
|
||||
'payouts' => [
|
||||
'total_count' => count($payouts),
|
||||
'total_coins' => $this->roundOrNull(array_sum(array_map(static fn (array $payout): float => (float) ($payout['coins_amount'] ?? 0), $payouts)), 6),
|
||||
'current_visible_coins' => $this->roundOrNull((float) ($latest['coins_total_visible'] ?? $latest['coins_total']), 6),
|
||||
'current_effective_coins' => $this->roundOrNull((float) ($latest['coins_total_effective'] ?? $latest['coins_total']), 6),
|
||||
],
|
||||
'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4),
|
||||
'miner_offers' => $offerSummary,
|
||||
];
|
||||
}
|
||||
|
||||
public function dashboardData(array $measurements, string $xField, string $yField, string $aggregation, array $filters = []): array
|
||||
{
|
||||
$allowedX = ['measured_at', 'measured_date', 'source', 'price_currency', 'trend_label'];
|
||||
$allowedY = [
|
||||
'coins_total',
|
||||
'price_per_coin',
|
||||
'growth_since_baseline',
|
||||
'doge_per_hour_since_baseline',
|
||||
'doge_per_day_since_baseline',
|
||||
'doge_per_hour_interval',
|
||||
'doge_per_day_interval',
|
||||
'current_value',
|
||||
'theoretical_daily_revenue',
|
||||
'theoretical_daily_profit',
|
||||
];
|
||||
|
||||
if (!in_array($xField, $allowedX, true) || !in_array($yField, $allowedY, true)) {
|
||||
throw new ApiException('Dashboard-Felder sind nicht erlaubt.', 422, ['x_field' => $xField, 'y_field' => $yField]);
|
||||
}
|
||||
|
||||
$filtered = array_values(array_filter($measurements, static function (array $row) use ($filters): bool {
|
||||
if (!empty($filters['source']) && $row['source'] !== $filters['source']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($filters['currency']) && $row['price_currency'] !== $filters['currency']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from']) && (string) $row['measured_at'] < (string) $filters['date_from']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to']) && (string) $row['measured_at'] > (string) $filters['date_to']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
if ($aggregation === 'none') {
|
||||
return array_map(static fn (array $row): array => [
|
||||
'x' => $row[$xField] ?? null,
|
||||
'y' => $row[$yField] ?? null,
|
||||
'row' => $row,
|
||||
], $filtered);
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($filtered as $row) {
|
||||
$key = (string) ($row[$xField] ?? 'unknown');
|
||||
$groups[$key][] = $row;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($groups as $key => $rows) {
|
||||
$values = array_values(array_filter(array_map(static fn (array $row) => $row[$yField] ?? null, $rows), static fn ($value): bool => $value !== null));
|
||||
$aggregated = match ($aggregation) {
|
||||
'avg' => $values === [] ? null : array_sum($values) / count($values),
|
||||
'sum' => $values === [] ? null : array_sum($values),
|
||||
'min' => $values === [] ? null : min($values),
|
||||
'max' => $values === [] ? null : max($values),
|
||||
'count' => count($rows),
|
||||
'latest' => $rows[array_key_last($rows)][$yField] ?? null,
|
||||
default => null,
|
||||
};
|
||||
|
||||
$result[] = [
|
||||
'x' => $key,
|
||||
'y' => $this->roundOrNull(is_numeric($aggregated) ? (float) $aggregated : null, 6),
|
||||
'points' => count($rows),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function roundOrNull(?float $value, int $precision): ?float
|
||||
{
|
||||
return $value === null ? null : round($value, $precision);
|
||||
}
|
||||
|
||||
private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency): ?float
|
||||
{
|
||||
if ($currency === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dailyTotal = 0.0;
|
||||
$matched = false;
|
||||
foreach ($costPlans as $plan) {
|
||||
if (empty($plan['is_active'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? ''));
|
||||
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
|
||||
if ($startTs <= 0 || $runtimeMonths <= 0 || $measurementTs < $startTs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$runtimeDays = $runtimeMonths * 30.4375;
|
||||
$endTs = (int) round($startTs + ($runtimeDays * 86400));
|
||||
$isCovered = !empty($plan['auto_renew']) || $measurementTs <= $endTs;
|
||||
if (!$isCovered) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$planDailyCost = (float) $plan['total_cost_amount'] / $runtimeDays;
|
||||
$convertedDailyCost = $this->convertAmount(
|
||||
$planDailyCost,
|
||||
(string) ($plan['currency'] ?? ''),
|
||||
$currency
|
||||
);
|
||||
if ($convertedDailyCost === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = true;
|
||||
$dailyTotal += $convertedDailyCost;
|
||||
}
|
||||
|
||||
return $matched ? $dailyTotal : null;
|
||||
}
|
||||
|
||||
private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency): ?float
|
||||
{
|
||||
foreach ($latestPriceByCurrency as $sourceCurrency => $price) {
|
||||
$converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency);
|
||||
if ($converted !== null) {
|
||||
return $converted;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function preferredPriceCurrency(array $latestPriceByCurrency, array $measurementDerivedPrices = []): ?string
|
||||
{
|
||||
foreach (['USD', 'EUR', 'DOGE'] as $preferredCurrency) {
|
||||
if (array_key_exists($preferredCurrency, $latestPriceByCurrency)) {
|
||||
return $preferredCurrency;
|
||||
}
|
||||
if (array_key_exists($preferredCurrency, $measurementDerivedPrices)) {
|
||||
return $preferredCurrency;
|
||||
}
|
||||
}
|
||||
|
||||
$first = array_key_first($latestPriceByCurrency);
|
||||
if (is_string($first)) {
|
||||
return $first;
|
||||
}
|
||||
|
||||
$derivedFirst = array_key_first($measurementDerivedPrices);
|
||||
return is_string($derivedFirst) ? $derivedFirst : null;
|
||||
}
|
||||
|
||||
private function measurementDerivedPrices(array $measurementRates, int $measurementId): array
|
||||
{
|
||||
if ($measurementId <= 0 || $measurementRates === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$prices = [];
|
||||
foreach ($measurementRates as $row) {
|
||||
if ((int) ($row['measurement_id'] ?? 0) !== $measurementId) {
|
||||
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;
|
||||
}
|
||||
|
||||
$prices[$quoteCurrency] = $rate;
|
||||
}
|
||||
|
||||
return $prices;
|
||||
}
|
||||
|
||||
private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency): ?float
|
||||
{
|
||||
if ($amount === null || $fromCurrency === null || $toCurrency === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = strtoupper(trim($fromCurrency));
|
||||
$to = strtoupper(trim($toCurrency));
|
||||
if ($from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
if ($this->fx === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->fx->convert($amount, $from, $to);
|
||||
}
|
||||
|
||||
private function totalHashrateMh(array $entries): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($entries as $entry) {
|
||||
if (array_key_exists('is_active', $entry) && empty($entry['is_active'])) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->entryIsCovered($entry, null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$total += $this->normalizeHashrateMh($entry['mining_speed_value'] ?? null, $entry['mining_speed_unit'] ?? null);
|
||||
$total += $this->normalizeHashrateMh($entry['bonus_speed_value'] ?? null, $entry['bonus_speed_unit'] ?? null);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null): ?float
|
||||
{
|
||||
$target = strtoupper(trim($targetCurrency));
|
||||
if ($target === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = 0.0;
|
||||
$matched = false;
|
||||
foreach ($purchasedMiners as $miner) {
|
||||
if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->entryIsCovered($miner, $measurementTs > 0 ? $measurementTs : null, 'purchased_at')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null;
|
||||
$currency = strtoupper(trim((string) ($miner['currency'] ?? '')));
|
||||
if ($amount === null || $amount <= 0 || $currency === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$converted = $this->convertAmount($amount, $currency, $target);
|
||||
if ($converted === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = true;
|
||||
$total += $converted;
|
||||
}
|
||||
|
||||
return $matched ? $total : null;
|
||||
}
|
||||
|
||||
private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency): ?float
|
||||
{
|
||||
$target = strtoupper(trim($targetCurrency));
|
||||
if ($target === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = 0.0;
|
||||
$matched = false;
|
||||
|
||||
$purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs);
|
||||
if ($purchasedTotal !== null) {
|
||||
$matched = true;
|
||||
$total += $purchasedTotal;
|
||||
}
|
||||
|
||||
foreach ($costPlans as $plan) {
|
||||
if (empty($plan['is_active'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? ''));
|
||||
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
|
||||
if ($startTs <= 0 || $runtimeMonths <= 0 || ($measurementTs > 0 && $measurementTs < $startTs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$runtimeDays = $runtimeMonths * 30.4375;
|
||||
$endTs = (int) round($startTs + ($runtimeDays * 86400));
|
||||
$isCovered = !empty($plan['auto_renew']) || $measurementTs <= 0 || $measurementTs <= $endTs;
|
||||
if (!$isCovered) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = is_numeric($plan['total_cost_amount'] ?? null) ? (float) $plan['total_cost_amount'] : null;
|
||||
$currency = strtoupper(trim((string) ($plan['currency'] ?? '')));
|
||||
if ($amount === null || $amount <= 0 || $currency === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$converted = $this->convertAmount($amount, $currency, $target);
|
||||
if ($converted === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = true;
|
||||
$total += $converted;
|
||||
}
|
||||
|
||||
return $matched ? $total : null;
|
||||
}
|
||||
|
||||
private function entryIsCovered(array $entry, ?int $measurementTs = null, string $startField = 'starts_at'): bool
|
||||
{
|
||||
$runtimeMonths = (int) ($entry['runtime_months'] ?? 0);
|
||||
if ($runtimeMonths <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$startTs = $this->utcTimestamp((string) ($entry[$startField] ?? ''));
|
||||
if ($startTs <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$checkTs = $measurementTs ?? time();
|
||||
if ($checkTs < $startTs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$runtimeDays = $runtimeMonths * 30.4375;
|
||||
$endTs = (int) round($startTs + ($runtimeDays * 86400));
|
||||
return !empty($entry['auto_renew']) || $checkTs <= $endTs;
|
||||
}
|
||||
|
||||
private function normalizeHashrateMh(mixed $value, mixed $unit): float
|
||||
{
|
||||
if (!is_numeric($value) || !is_string($unit) || trim($unit) === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$numeric = (float) $value;
|
||||
return match (trim($unit)) {
|
||||
'MH/s' => $numeric,
|
||||
'kH/s' => $numeric / 1000,
|
||||
default => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
private function evaluateMinerOffer(array $offer, array $latest, array $latestPriceByCurrency, float $currentHashrateMh, array $settings): array
|
||||
{
|
||||
$offerHashrateMh = $this->normalizeHashrateMh($offer['mining_speed_value'] ?? null, $offer['mining_speed_unit'] ?? null)
|
||||
+ $this->normalizeHashrateMh($offer['bonus_speed_value'] ?? null, $offer['bonus_speed_unit'] ?? null);
|
||||
|
||||
$paymentType = (string) ($offer['payment_type'] ?? '');
|
||||
if ($paymentType === '') {
|
||||
$paymentType = !empty($offer['price_currency']) && in_array(strtoupper((string) $offer['price_currency']), ['ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP'], true)
|
||||
? 'crypto'
|
||||
: 'fiat';
|
||||
}
|
||||
$basePriceAmount = is_numeric($offer['base_price_amount'] ?? null)
|
||||
? (float) $offer['base_price_amount']
|
||||
: (is_numeric($offer['reference_price_amount'] ?? null)
|
||||
? (float) $offer['reference_price_amount']
|
||||
: (is_numeric($offer['usd_reference_amount'] ?? null)
|
||||
? (float) $offer['usd_reference_amount']
|
||||
: (is_numeric($offer['price_amount'] ?? null) ? (float) $offer['price_amount'] : null)));
|
||||
$basePriceCurrency = (string) ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : ($offer['price_currency'] ?? '')));
|
||||
$effectivePriceCurrency = $paymentType === 'crypto'
|
||||
? (string) ($settings['crypto_currency'] ?? 'DOGE')
|
||||
: $basePriceCurrency;
|
||||
$effectivePriceAmount = $basePriceAmount;
|
||||
if ($basePriceAmount !== null && $basePriceAmount > 0 && $basePriceCurrency !== '' && $effectivePriceCurrency !== '' && strtoupper($basePriceCurrency) !== strtoupper($effectivePriceCurrency)) {
|
||||
$convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency);
|
||||
if ($convertedReference !== null && $convertedReference > 0) {
|
||||
$effectivePriceAmount = $convertedReference;
|
||||
}
|
||||
}
|
||||
$referencePriceAmount = $basePriceAmount;
|
||||
$referencePriceCurrency = $basePriceCurrency !== '' ? $basePriceCurrency : null;
|
||||
|
||||
$expectedDogePerDay = null;
|
||||
if ($currentHashrateMh > 0 && $offerHashrateMh > 0 && is_numeric($latest['doge_per_day_interval'] ?? null)) {
|
||||
$expectedDogePerDay = ((float) $latest['doge_per_day_interval'] / $currentHashrateMh) * $offerHashrateMh;
|
||||
}
|
||||
|
||||
$offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency)) : null;
|
||||
$expectedDailyRevenue = ($expectedDogePerDay !== null && $offerCurrencyPrice !== null)
|
||||
? $expectedDogePerDay * $offerCurrencyPrice
|
||||
: null;
|
||||
$breakEvenDays = ($effectivePriceAmount !== null && $expectedDailyRevenue !== null && $expectedDailyRevenue > 0)
|
||||
? $effectivePriceAmount / $expectedDailyRevenue
|
||||
: null;
|
||||
|
||||
$recommendation = 'keine Basis';
|
||||
if ($breakEvenDays !== null) {
|
||||
if ($breakEvenDays <= 180) {
|
||||
$recommendation = 'lohnt eher';
|
||||
} elseif ($breakEvenDays <= 365) {
|
||||
$recommendation = 'abwaegen';
|
||||
} else {
|
||||
$recommendation = 'eher warten';
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($offer, [
|
||||
'payment_type' => $paymentType,
|
||||
'base_price_amount' => $this->roundOrNull($basePriceAmount, 8),
|
||||
'base_price_currency' => $basePriceCurrency !== '' ? $basePriceCurrency : null,
|
||||
'offer_hashrate_mh' => $this->roundOrNull($offerHashrateMh, 4),
|
||||
'effective_price_amount' => $this->roundOrNull($effectivePriceAmount, 8),
|
||||
'effective_price_currency' => $effectivePriceCurrency,
|
||||
'reference_price_amount' => $this->roundOrNull($referencePriceAmount, 8),
|
||||
'reference_price_currency' => $referencePriceCurrency !== '' ? $referencePriceCurrency : null,
|
||||
'expected_doge_per_day' => $this->roundOrNull($expectedDogePerDay, 6),
|
||||
'expected_daily_revenue' => $this->roundOrNull($expectedDailyRevenue, 8),
|
||||
'break_even_days' => $this->roundOrNull($breakEvenDays, 2),
|
||||
'recommendation' => $recommendation,
|
||||
]);
|
||||
}
|
||||
|
||||
private function enrichOfferScenario(array $offer, array $latest, float $currentHashrateMh, array $costPlans, array $purchasedMiners): array
|
||||
{
|
||||
$latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '');
|
||||
$currentDogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null;
|
||||
$currentDailyProfit = is_numeric($latest['theoretical_daily_profit'] ?? null) ? (float) $latest['theoretical_daily_profit'] : null;
|
||||
$currentDailyCost = is_numeric($latest['effective_daily_cost'] ?? null) ? (float) $latest['effective_daily_cost'] : null;
|
||||
$investedCapital = is_numeric($latest['invested_capital'] ?? null) ? (float) $latest['invested_capital'] : null;
|
||||
$effectivePricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null;
|
||||
$expectedDogePerDay = is_numeric($offer['expected_doge_per_day'] ?? null) ? (float) $offer['expected_doge_per_day'] : null;
|
||||
$offerPriceAmount = is_numeric($offer['effective_price_amount'] ?? null) ? (float) $offer['effective_price_amount'] : null;
|
||||
$offerPriceCurrency = (string) ($offer['effective_price_currency'] ?? '');
|
||||
|
||||
$scenarioOfferCost = (
|
||||
$offerPriceAmount !== null &&
|
||||
$offerPriceCurrency !== '' &&
|
||||
$latestCurrency !== ''
|
||||
) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency) : null;
|
||||
$scenarioDogePerDay = (
|
||||
$currentDogePerDay !== null &&
|
||||
$expectedDogePerDay !== null
|
||||
) ? ($currentDogePerDay + $expectedDogePerDay) : null;
|
||||
$scenarioDailyRevenue = (
|
||||
$scenarioDogePerDay !== null &&
|
||||
$effectivePricePerCoin !== null
|
||||
) ? ($scenarioDogePerDay * $effectivePricePerCoin) : null;
|
||||
$scenarioDailyProfit = (
|
||||
$scenarioDailyRevenue !== null &&
|
||||
$currentDailyCost !== null
|
||||
) ? ($scenarioDailyRevenue - $currentDailyCost) : null;
|
||||
$scenarioInvestedCapital = (
|
||||
$investedCapital !== null &&
|
||||
$scenarioOfferCost !== null
|
||||
) ? ($investedCapital + $scenarioOfferCost) : null;
|
||||
$scenarioRemainingAmount = $scenarioInvestedCapital;
|
||||
$scenarioBreakEvenDays = (
|
||||
$scenarioInvestedCapital !== null &&
|
||||
$scenarioDailyRevenue !== null &&
|
||||
$scenarioDailyRevenue > 0
|
||||
) ? ($scenarioInvestedCapital / $scenarioDailyRevenue) : null;
|
||||
$scenarioDate = null;
|
||||
$measuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? ''));
|
||||
if ($measuredTs > 0 && $scenarioBreakEvenDays !== null) {
|
||||
$scenarioDate = $this->formatUtcTimestamp((int) round($measuredTs + ($scenarioBreakEvenDays * 86400)));
|
||||
}
|
||||
|
||||
$scenarioPurchasedMiners = $purchasedMiners;
|
||||
if ($scenarioOfferCost !== null && $latestCurrency !== '') {
|
||||
$scenarioPurchasedMiners[] = [
|
||||
'purchased_at' => $latest['measured_at'] ?? $this->currentUtcDateTime(),
|
||||
'runtime_months' => $offer['runtime_months'] ?? null,
|
||||
'mining_speed_value' => $offer['mining_speed_value'] ?? null,
|
||||
'mining_speed_unit' => $offer['mining_speed_unit'] ?? null,
|
||||
'bonus_speed_value' => $offer['bonus_speed_value'] ?? null,
|
||||
'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null,
|
||||
'total_cost_amount' => $scenarioOfferCost,
|
||||
'currency' => $latestCurrency,
|
||||
'auto_renew' => !empty($offer['auto_renew']) ? 1 : 0,
|
||||
'is_active' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
$currentProjection = $this->projectPerformance($costPlans, $purchasedMiners, $latest, 730);
|
||||
$scenarioProjection = $this->projectPerformance($costPlans, $scenarioPurchasedMiners, $latest, 730);
|
||||
|
||||
return array_merge($offer, [
|
||||
'scenario_currency' => $latestCurrency !== '' ? $latestCurrency : null,
|
||||
'scenario_current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4),
|
||||
'scenario_hashrate_mh' => $this->roundOrNull($currentHashrateMh + (float) ($offer['offer_hashrate_mh'] ?? 0), 4),
|
||||
'scenario_current_doge_per_day' => $this->roundOrNull($currentDogePerDay, 6),
|
||||
'scenario_doge_per_day' => $this->roundOrNull($scenarioDogePerDay, 6),
|
||||
'scenario_current_daily_profit' => $this->roundOrNull($currentDailyProfit, 8),
|
||||
'scenario_daily_profit' => $this->roundOrNull($scenarioDailyProfit, 8),
|
||||
'scenario_daily_profit_delta' => (
|
||||
$scenarioDailyProfit !== null &&
|
||||
$currentDailyProfit !== null
|
||||
) ? $this->roundOrNull($scenarioDailyProfit - $currentDailyProfit, 8) : null,
|
||||
'scenario_current_invested_capital' => $this->roundOrNull($investedCapital, 8),
|
||||
'scenario_invested_capital' => $this->roundOrNull($scenarioInvestedCapital, 8),
|
||||
'scenario_offer_cost' => $this->roundOrNull($scenarioOfferCost, 8),
|
||||
'scenario_break_even_remaining_amount' => $this->roundOrNull($scenarioRemainingAmount, 8),
|
||||
'scenario_break_even_days' => $this->roundOrNull($scenarioBreakEvenDays, 4),
|
||||
'scenario_break_even_date' => $scenarioDate,
|
||||
'scenario_projection_days' => $scenarioProjection['days'],
|
||||
'scenario_current_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8),
|
||||
'scenario_two_year_profit' => $this->roundOrNull($scenarioProjection['profit'], 8),
|
||||
'scenario_two_year_profit_delta' => (
|
||||
$scenarioProjection['profit'] !== null &&
|
||||
$currentProjection['profit'] !== null
|
||||
) ? $this->roundOrNull($scenarioProjection['profit'] - $currentProjection['profit'], 8) : null,
|
||||
'scenario_two_year_revenue' => $this->roundOrNull($scenarioProjection['revenue'], 8),
|
||||
'scenario_two_year_cost' => $this->roundOrNull($scenarioProjection['cost'], 8),
|
||||
]);
|
||||
}
|
||||
|
||||
private function projectPerformance(array $costPlans, array $purchasedMiners, array $latest, int $days): array
|
||||
{
|
||||
$currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '')));
|
||||
$pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null;
|
||||
$dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null;
|
||||
$currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners));
|
||||
$baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? ''));
|
||||
if ($baseTs <= 0) {
|
||||
$baseTs = $this->utcTimestamp($this->currentUtcDateTime());
|
||||
}
|
||||
|
||||
if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $days <= 0) {
|
||||
return ['days' => $days, 'revenue' => null, 'cost' => null, 'profit' => null];
|
||||
}
|
||||
|
||||
$dogePerDayPerMh = $dogePerDay / $currentHashrateMh;
|
||||
$revenue = 0.0;
|
||||
$cost = 0.0;
|
||||
|
||||
for ($day = 0; $day < $days; $day++) {
|
||||
$checkTs = $baseTs + ($day * 86400);
|
||||
$activeHashrate = 0.0;
|
||||
|
||||
foreach ($costPlans as $plan) {
|
||||
if (empty($plan['is_active']) || !$this->entryIsCovered($plan, $checkTs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$activeHashrate += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null);
|
||||
$activeHashrate += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null);
|
||||
|
||||
$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);
|
||||
if ($dailyCost !== null) {
|
||||
$cost += $dailyCost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($purchasedMiners as $miner) {
|
||||
if ((array_key_exists('is_active', $miner) && empty($miner['is_active'])) || !$this->entryIsCovered($miner, $checkTs, 'purchased_at')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$activeHashrate += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null);
|
||||
$activeHashrate += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null);
|
||||
|
||||
$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);
|
||||
if ($dailyCost !== null) {
|
||||
$cost += $dailyCost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$revenue += $activeHashrate * $dogePerDayPerMh * $pricePerCoin;
|
||||
}
|
||||
|
||||
return [
|
||||
'days' => $days,
|
||||
'revenue' => $revenue,
|
||||
'cost' => $cost,
|
||||
'profit' => $revenue - $cost,
|
||||
];
|
||||
}
|
||||
|
||||
private function utcTimestamp(?string $value): int
|
||||
{
|
||||
$normalized = trim((string) $value);
|
||||
if ($normalized === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$utc = new \DateTimeZone('UTC');
|
||||
$formats = ['Y-m-d H:i:s', 'Y-m-d H:i', \DateTimeInterface::ATOM];
|
||||
foreach ($formats as $format) {
|
||||
$date = \DateTimeImmutable::createFromFormat($format, $normalized, $utc);
|
||||
if ($date instanceof \DateTimeImmutable) {
|
||||
return $date->getTimestamp();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($normalized, $utc))->getTimestamp();
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function formatUtcTimestamp(int $timestamp): string
|
||||
{
|
||||
return (new \DateTimeImmutable('@' . $timestamp))
|
||||
->setTimezone(new \DateTimeZone('UTC'))
|
||||
->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
private function currentUtcDateTime(): string
|
||||
{
|
||||
return $this->formatUtcTimestamp(time());
|
||||
}
|
||||
}
|
||||
759
modules/mining-checker/src/Domain/FxService.php
Normal file
759
modules/mining-checker/src/Domain/FxService.php
Normal file
@@ -0,0 +1,759 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
use Modules\MiningChecker\Support\DebugTrace;
|
||||
|
||||
final class FxService
|
||||
{
|
||||
private ?MiningRepository $repository;
|
||||
private string $provider;
|
||||
private string $apiBaseUrl;
|
||||
private string $currenciesApiBaseUrl;
|
||||
private string $apiKey;
|
||||
private int $timeout;
|
||||
private int $cacheTtl;
|
||||
private bool $autoFetchOnMiss;
|
||||
private array $memoryCache = [];
|
||||
private ?DebugTrace $debug;
|
||||
|
||||
public function __construct(
|
||||
?MiningRepository $repository = null,
|
||||
string $apiBaseUrl = 'https://currencyapi.net',
|
||||
string $currenciesApiBaseUrl = 'https://currencyapi.net',
|
||||
int $timeout = 10,
|
||||
int $cacheTtl = 21600,
|
||||
bool $autoFetchOnMiss = false,
|
||||
string $provider = 'currencyapi',
|
||||
string $apiKey = '',
|
||||
?DebugTrace $debug = null
|
||||
)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi';
|
||||
$this->apiBaseUrl = rtrim($apiBaseUrl, '/');
|
||||
$this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/');
|
||||
$this->apiKey = trim($apiKey);
|
||||
$this->timeout = max(2, $timeout);
|
||||
$this->cacheTtl = max(60, $cacheTtl);
|
||||
$this->autoFetchOnMiss = $autoFetchOnMiss;
|
||||
$this->debug = $debug;
|
||||
}
|
||||
|
||||
public function convert(?float $amount, ?string $from, ?string $to): ?float
|
||||
{
|
||||
if ($amount === null || $from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $this->rate($from, $to);
|
||||
return $rate === null ? null : $amount * $rate;
|
||||
}
|
||||
|
||||
public function rate(?string $from, ?string $to): ?float
|
||||
{
|
||||
$base = strtoupper(trim((string) $from));
|
||||
$target = strtoupper(trim((string) $to));
|
||||
|
||||
if ($base === '' || $target === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($base === $target) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$cacheKey = $base . ':' . $target;
|
||||
if (array_key_exists($cacheKey, $this->memoryCache)) {
|
||||
return $this->memoryCache[$cacheKey];
|
||||
}
|
||||
|
||||
$stored = $this->storedRate($base, $target);
|
||||
if ($stored !== null) {
|
||||
$this->memoryCache[$cacheKey] = $stored;
|
||||
return $stored;
|
||||
}
|
||||
|
||||
$cached = $this->readFileCache($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$this->memoryCache[$cacheKey] = $cached;
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$this->autoFetchOnMiss) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $this->fetchAndPersistRate($base, $target);
|
||||
$this->memoryCache[$cacheKey] = $rate;
|
||||
if ($rate !== null) {
|
||||
$this->writeFileCache($cacheKey, $rate);
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
|
||||
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
|
||||
{
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$targets = $currencies === null
|
||||
? null
|
||||
: array_values(array_unique(array_filter(array_map(
|
||||
static fn ($code): string => strtoupper(trim((string) $code)),
|
||||
$currencies
|
||||
), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase)));
|
||||
|
||||
$payload = $this->fetchLatestPayload($normalizedBase, $targets);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$forwardRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === '' || $targetCode === $normalizedBase) {
|
||||
continue;
|
||||
}
|
||||
$forwardRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
|
||||
$updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $rateDate,
|
||||
'updated_count' => count($updated),
|
||||
'rates' => $updated,
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
|
||||
{
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
|
||||
|
||||
if ($this->repository === null) {
|
||||
return $this->refreshLatestRates(null, $normalizedBase);
|
||||
}
|
||||
|
||||
$latestFetch = $this->repository->getLatestFxFetch($normalizedBase);
|
||||
$latestFetchedAt = is_array($latestFetch) ? strtotime((string) ($latestFetch['fetched_at'] ?? '')) : false;
|
||||
$ageSeconds = $latestFetchedAt ? (time() - $latestFetchedAt) : null;
|
||||
$maxAgeSeconds = (int) round($maxAgeHours * 3600);
|
||||
|
||||
if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) {
|
||||
$this->debug?->add('fx.latest.reuse', [
|
||||
'base' => $normalizedBase,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $latestFetch['rate_date'] ?? null,
|
||||
'updated_count' => 0,
|
||||
'rates' => [],
|
||||
'reused' => true,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.refresh_required', [
|
||||
'base' => $normalizedBase,
|
||||
'previous_fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
$result = $this->refreshLatestRates(null, $normalizedBase);
|
||||
$result['reused'] = false;
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function probeLatestRates(string $base = 'EUR'): array
|
||||
{
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
return $this->fetchLatestProbe($normalizedBase);
|
||||
}
|
||||
|
||||
public function refreshCurrencyCatalog(): array
|
||||
{
|
||||
if ($this->repository === null) {
|
||||
return [
|
||||
'synced_count' => 0,
|
||||
'currencies' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
$items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
|
||||
if ($items === []) {
|
||||
return [
|
||||
'synced_count' => 0,
|
||||
'currencies' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$synced = [];
|
||||
$sortOrder = 1000;
|
||||
|
||||
foreach ($items as $code => $name) {
|
||||
$normalizedCode = strtoupper(trim((string) $code));
|
||||
$normalizedName = trim((string) $name);
|
||||
if ($normalizedCode === '' || $normalizedName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currency = [
|
||||
'code' => substr($normalizedCode, 0, 10),
|
||||
'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64),
|
||||
'symbol' => substr($normalizedCode, 0, 8),
|
||||
'is_active' => 1,
|
||||
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
|
||||
'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder),
|
||||
];
|
||||
|
||||
$synced[] = $currency;
|
||||
$sortOrder++;
|
||||
}
|
||||
|
||||
$this->repository->saveCurrencies($synced);
|
||||
|
||||
usort($synced, static function (array $left, array $right): int {
|
||||
return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']];
|
||||
});
|
||||
|
||||
return [
|
||||
'synced_count' => count($synced),
|
||||
'currencies' => $synced,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeCurrencyCatalog(): array
|
||||
{
|
||||
return $this->fetchCurrenciesProbe();
|
||||
}
|
||||
|
||||
private function fetchAndPersistRate(string $base, string $target): ?float
|
||||
{
|
||||
$payload = $this->fetchLatestPayload($base, [$target]);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rate = $rates[$target] ?? null;
|
||||
if (!is_numeric($rate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numericRate = (float) $rate;
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$this->persistRateSet($base, [$target => $numericRate], $rateDate);
|
||||
return $numericRate;
|
||||
}
|
||||
|
||||
private function fetchLatestPayload(string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, $targets);
|
||||
if ($url === null) {
|
||||
$this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
'targets' => $targets,
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.latest.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
return $this->normalizePayload($payload, $base, $targets);
|
||||
}
|
||||
|
||||
private function fetchLatestProbe(string $base): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.'];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, null);
|
||||
if ($url === null) {
|
||||
return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.probe.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.latest.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesPayload(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.currencies.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesProbe(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function storedRate(string $base, string $target): ?float
|
||||
{
|
||||
if ($this->repository === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$direct = $this->repository->getLatestFxRate($base, $target);
|
||||
if (is_array($direct) && is_numeric($direct['rate'] ?? null)) {
|
||||
return (float) $direct['rate'];
|
||||
}
|
||||
|
||||
$inverse = $this->repository->getLatestFxRate($target, $base);
|
||||
if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) {
|
||||
return 1 / (float) $inverse['rate'];
|
||||
}
|
||||
|
||||
$measurementRate = $this->repository->getLatestMeasurementRate($base, $target);
|
||||
if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) {
|
||||
return (float) $measurementRate['rate'];
|
||||
}
|
||||
|
||||
$inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base);
|
||||
if (
|
||||
is_array($inverseMeasurementRate) &&
|
||||
is_numeric($inverseMeasurementRate['rate'] ?? null) &&
|
||||
(float) $inverseMeasurementRate['rate'] > 0
|
||||
) {
|
||||
return 1 / (float) $inverseMeasurementRate['rate'];
|
||||
}
|
||||
|
||||
foreach (['USD', 'EUR'] as $viaBase) {
|
||||
if ($base === $viaBase || $target === $viaBase) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fromVia = $this->repository->getLatestFxRate($viaBase, $base);
|
||||
$toVia = $this->repository->getLatestFxRate($viaBase, $target);
|
||||
if (
|
||||
is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) &&
|
||||
is_array($toVia) && is_numeric($toVia['rate'] ?? null) &&
|
||||
(float) $fromVia['rate'] > 0
|
||||
) {
|
||||
return (float) $toVia['rate'] / (float) $fromVia['rate'];
|
||||
}
|
||||
|
||||
$fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase);
|
||||
$toViaInverse = $this->repository->getLatestFxRate($target, $viaBase);
|
||||
if (
|
||||
is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) &&
|
||||
is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) &&
|
||||
(float) $toViaInverse['rate'] > 0
|
||||
) {
|
||||
return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistRateSet(string $base, array $rates, string $rateDate): array
|
||||
{
|
||||
$normalizedBase = strtoupper($base);
|
||||
$normalizedRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedTarget = strtoupper((string) $target);
|
||||
$normalizedRates[$normalizedTarget] = (float) $rate;
|
||||
$this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate;
|
||||
$this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate);
|
||||
}
|
||||
|
||||
if ($this->repository === null) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates);
|
||||
return is_array($saved['rates'] ?? null) ? $saved['rates'] : [];
|
||||
} catch (\Throwable) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildLatestUrl(string $base, ?array $targets = null): ?string
|
||||
{
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if ($this->apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s/api/v2/rates?base=%s&output=json&key=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
$targets = $targets ?? $this->defaultCurrencies();
|
||||
return sprintf(
|
||||
'%s/latest?base=%s&symbols=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode(implode(',', $targets))
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!is_array($payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
|
||||
}
|
||||
|
||||
$allRates = $payload['rates'];
|
||||
$filteredRates = [];
|
||||
if ($targets === null) {
|
||||
foreach ($allRates as $target => $rate) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
} else {
|
||||
foreach ($targets as $target) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rate = $allRates[$targetCode] ?? null;
|
||||
if (is_numeric($rate)) {
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => strtoupper((string) ($payload['base'] ?? $base)),
|
||||
'date' => $payload['updated'] ?? null,
|
||||
'rates' => $filteredRates,
|
||||
];
|
||||
}
|
||||
|
||||
if (!is_array($payload['rates'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function extractProviderError(array $payload, string $fallback): string
|
||||
{
|
||||
foreach (['error', 'message', 'msg'] as $field) {
|
||||
$value = $payload[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
$errors = $payload['errors'] ?? null;
|
||||
if (is_array($errors)) {
|
||||
$flat = [];
|
||||
array_walk_recursive($errors, static function ($value) use (&$flat): void {
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$flat[] = trim($value);
|
||||
}
|
||||
});
|
||||
if ($flat !== []) {
|
||||
return implode(' | ', array_values(array_unique($flat)));
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function defaultCurrencies(): array
|
||||
{
|
||||
if ($this->repository === null) {
|
||||
return ['EUR', 'USD'];
|
||||
}
|
||||
|
||||
try {
|
||||
$currencies = $this->repository->listActiveFiatCurrencies();
|
||||
return array_map(static fn (array $currency): string => (string) $currency['code'], $currencies);
|
||||
} catch (\Throwable) {
|
||||
return ['EUR', 'USD'];
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeRateDate(mixed $value): string
|
||||
{
|
||||
if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) {
|
||||
$timestamp = (int) $value;
|
||||
if ($timestamp > 0) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) {
|
||||
return $matches[0];
|
||||
}
|
||||
}
|
||||
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
private function catalogSortOrder(string $code, int $fallback): int
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'EUR' => 10,
|
||||
'USD' => 20,
|
||||
'DOGE' => 30,
|
||||
'BTC' => 40,
|
||||
'ETH' => 50,
|
||||
'USDT' => 60,
|
||||
'USDC' => 70,
|
||||
default => $fallback,
|
||||
};
|
||||
}
|
||||
|
||||
private function isCryptoCode(string $code): bool
|
||||
{
|
||||
return in_array(strtoupper($code), [
|
||||
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
|
||||
'SOL', 'USDC', 'USDT', 'XRP',
|
||||
], true);
|
||||
}
|
||||
|
||||
private function cacheFile(string $cacheKey): string
|
||||
{
|
||||
return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json';
|
||||
}
|
||||
|
||||
private function readFileCache(string $cacheKey): ?float
|
||||
{
|
||||
$file = $this->cacheFile($cacheKey);
|
||||
if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents($file), true);
|
||||
$rate = $payload['rate'] ?? null;
|
||||
return is_numeric($rate) ? (float) $rate : null;
|
||||
}
|
||||
|
||||
private function writeFileCache(string $cacheKey, float $rate): void
|
||||
{
|
||||
@file_put_contents($this->cacheFile($cacheKey), json_encode([
|
||||
'rate' => $rate,
|
||||
'cached_at' => time(),
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
private function maskUrl(string $url): string
|
||||
{
|
||||
return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string {
|
||||
$key = $matches[2] ?? '';
|
||||
if (strlen($key) <= 8) {
|
||||
return $matches[1] . $key;
|
||||
}
|
||||
|
||||
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
|
||||
}, $url) ?: $url;
|
||||
}
|
||||
}
|
||||
400
modules/mining-checker/src/Domain/OcrService.php
Normal file
400
modules/mining-checker/src/Domain/OcrService.php
Normal file
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\ModuleConfig;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
|
||||
final class OcrService
|
||||
{
|
||||
private ModuleConfig $config;
|
||||
|
||||
public function __construct(ModuleConfig $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function preview(array $file, array $input): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
|
||||
}
|
||||
|
||||
$mime = mime_content_type($file['tmp_name']) ?: '';
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
|
||||
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
|
||||
}
|
||||
|
||||
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
|
||||
$uploadDir = $this->resolveUploadDir($projectKey);
|
||||
|
||||
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
|
||||
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
|
||||
$targetFile = $uploadDir . '/' . $filename;
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
|
||||
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
|
||||
}
|
||||
|
||||
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
|
||||
$flags = [];
|
||||
|
||||
if ($rawText === '') {
|
||||
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
|
||||
$flags = array_merge($flags, $providerFlags);
|
||||
} else {
|
||||
$flags[] = 'ocr_hint_text_used';
|
||||
}
|
||||
|
||||
$parsed = $this->parseText($rawText, (string) ($input['date_context'] ?? date('Y-m-d')));
|
||||
$parsed['image_path'] = $targetFile;
|
||||
$parsed['raw_text'] = $rawText;
|
||||
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function resolveUploadDir(string $projectKey): string
|
||||
{
|
||||
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
|
||||
$candidates = [
|
||||
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
|
||||
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->ensureWritableDirectory($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
|
||||
'candidates' => $candidates,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureWritableDirectory(string $directory): bool
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return is_writable($directory);
|
||||
}
|
||||
|
||||
return @mkdir($directory, 0775, true) || is_dir($directory);
|
||||
}
|
||||
|
||||
private function extractRawText(string $imagePath): array
|
||||
{
|
||||
$ocrConfig = $this->config->ocr();
|
||||
$providers = $ocrConfig['providers'] ?? ['tesseract'];
|
||||
$flags = [];
|
||||
|
||||
if (!is_array($providers) || $providers === []) {
|
||||
$providers = ['tesseract'];
|
||||
}
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
$providerName = strtolower(trim((string) $provider));
|
||||
if ($providerName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($providerName === 'ocrspace') {
|
||||
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
|
||||
} elseif ($providerName === 'tesseract') {
|
||||
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
|
||||
} else {
|
||||
$flags[] = 'ocr_provider_unsupported:' . $providerName;
|
||||
continue;
|
||||
}
|
||||
|
||||
$flags = array_merge($flags, $result['flags']);
|
||||
if (($result['text'] ?? '') !== '') {
|
||||
return [
|
||||
'text' => (string) $result['text'],
|
||||
'flags' => array_values(array_unique(array_merge(
|
||||
$flags,
|
||||
['ocr_provider:' . $providerName]
|
||||
))),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
|
||||
];
|
||||
}
|
||||
|
||||
private function runOcrSpace(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
|
||||
];
|
||||
}
|
||||
|
||||
$url = trim((string) ($providerConfig['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$postFields = [
|
||||
'file' => new \CURLFile($imagePath),
|
||||
'language' => (string) ($providerConfig['language'] ?? 'eng'),
|
||||
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
|
||||
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
|
||||
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
|
||||
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
|
||||
'isOverlayRequired' => 'false',
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postFields,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'apikey: ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
|
||||
];
|
||||
}
|
||||
|
||||
$flags = [];
|
||||
$rawText = '';
|
||||
$parsedResults = $payload['ParsedResults'] ?? null;
|
||||
if (is_array($parsedResults)) {
|
||||
$texts = [];
|
||||
foreach ($parsedResults as $result) {
|
||||
if (!is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
|
||||
if ($fileExitCode !== '') {
|
||||
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
|
||||
}
|
||||
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
|
||||
if ($parsedText !== '') {
|
||||
$texts[] = $parsedText;
|
||||
}
|
||||
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
|
||||
if ($resultError !== '') {
|
||||
$flags[] = 'ocrspace_result_error';
|
||||
}
|
||||
}
|
||||
$rawText = trim(implode("\n", $texts));
|
||||
}
|
||||
|
||||
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
|
||||
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
|
||||
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
|
||||
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
|
||||
|
||||
if ($httpStatus >= 400) {
|
||||
$flags[] = 'ocrspace_http_error';
|
||||
}
|
||||
|
||||
if ($ocrExitCode !== '') {
|
||||
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
|
||||
}
|
||||
|
||||
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
|
||||
|
||||
if ($isErroredOnProcessing) {
|
||||
$flags[] = 'ocrspace_processing_error';
|
||||
}
|
||||
|
||||
if ($errorMessage !== '' || $errorDetails !== '') {
|
||||
$flags[] = 'ocrspace_error';
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => $rawText,
|
||||
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
|
||||
];
|
||||
}
|
||||
|
||||
private function runTesseract(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
|
||||
if (!$this->binaryExists($binary)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
$language = (string) ($providerConfig['language'] ?? 'eng');
|
||||
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
|
||||
if ($tmpBase === false) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_tempfile_failed:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
@unlink($tmpBase);
|
||||
$command = sprintf(
|
||||
'%s %s %s -l %s 2>/dev/null',
|
||||
escapeshellcmd($binary),
|
||||
escapeshellarg($imagePath),
|
||||
escapeshellarg($tmpBase),
|
||||
escapeshellarg($language)
|
||||
);
|
||||
shell_exec($command);
|
||||
|
||||
$txtFile = $tmpBase . '.txt';
|
||||
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
|
||||
@unlink($txtFile);
|
||||
return [
|
||||
'text' => trim($text),
|
||||
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
private function binaryExists(string $binary): bool
|
||||
{
|
||||
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
|
||||
}
|
||||
|
||||
private function parseText(string $rawText, string $dateContext): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$coinsTotal = null;
|
||||
$price = null;
|
||||
$currency = null;
|
||||
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||
|
||||
if ($normalizedText === '') {
|
||||
$flags[] = 'ocr_raw_text_empty';
|
||||
}
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
|
||||
$decimalCandidates = [];
|
||||
foreach ($numberMatches[0] ?? [] as $candidate) {
|
||||
$normalized = (float) str_replace(',', '.', $candidate);
|
||||
if ($normalized <= 0) {
|
||||
continue;
|
||||
}
|
||||
$decimalCandidates[] = [
|
||||
'raw' => $candidate,
|
||||
'value' => $normalized,
|
||||
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
|
||||
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
|
||||
$currency = strtoupper((string) $pairMatch[1]);
|
||||
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
|
||||
$currency = strtoupper((string) $currencyMatch[1]);
|
||||
} elseif (str_contains($normalizedText, '$')) {
|
||||
$currency = 'USD';
|
||||
} else {
|
||||
$flags[] = 'currency_missing';
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
|
||||
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
|
||||
}
|
||||
|
||||
$coinsCandidates = array_values(array_filter($decimalCandidates, static fn (array $item): bool => $item['value'] > 10 && $item['precision'] >= 4));
|
||||
if ($coinsCandidates !== []) {
|
||||
usort($coinsCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
|
||||
});
|
||||
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
|
||||
if (count($coinsCandidates) > 1) {
|
||||
$flags[] = 'coins_ambiguous';
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'coins_missing';
|
||||
}
|
||||
|
||||
$priceCandidates = array_values(array_filter(
|
||||
$decimalCandidates,
|
||||
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
|
||||
));
|
||||
|
||||
if ($price === null && $priceCandidates !== []) {
|
||||
usort($priceCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
|
||||
});
|
||||
$price = round((float) $priceCandidates[0]['value'], 8);
|
||||
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
|
||||
$flags[] = 'price_ambiguous';
|
||||
}
|
||||
}
|
||||
|
||||
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
|
||||
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
|
||||
if ($fiatValue > 0) {
|
||||
$price = round($fiatValue / $coinsTotal, 8);
|
||||
$flags[] = 'price_derived_from_balance_value';
|
||||
$currency = $currency ?? 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$coinsTotal, $price, $currency] as $field) {
|
||||
if ($field !== null) {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) - (count($flags) * 0.04)));
|
||||
|
||||
return [
|
||||
'suggested' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'coins_total' => $coinsTotal,
|
||||
'price_per_coin' => $price,
|
||||
'price_currency' => $currency,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => $flags,
|
||||
];
|
||||
}
|
||||
}
|
||||
79
modules/mining-checker/src/Domain/SeedData.php
Normal file
79
modules/mining-checker/src/Domain/SeedData.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
final class SeedData
|
||||
{
|
||||
public static function projectKey(): string
|
||||
{
|
||||
return 'doge-main';
|
||||
}
|
||||
|
||||
public static function projectName(): string
|
||||
{
|
||||
return 'DOGE Mining Main';
|
||||
}
|
||||
|
||||
public static function settings(): array
|
||||
{
|
||||
return [
|
||||
'baseline_measured_at' => '2026-03-16 01:32:00',
|
||||
'baseline_coins_total' => 27.617864,
|
||||
'daily_cost_amount' => 0.3123287671,
|
||||
'daily_cost_currency' => 'EUR',
|
||||
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function currencies(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20],
|
||||
['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100],
|
||||
['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110],
|
||||
['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120],
|
||||
['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130],
|
||||
['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140],
|
||||
['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150],
|
||||
];
|
||||
}
|
||||
|
||||
public static function measurements(): array
|
||||
{
|
||||
return [
|
||||
['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
];
|
||||
}
|
||||
|
||||
public static function targets(): array
|
||||
{
|
||||
return [
|
||||
['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20],
|
||||
];
|
||||
}
|
||||
|
||||
public static function dashboards(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
];
|
||||
}
|
||||
}
|
||||
66
modules/mining-checker/src/Domain/SeedImporter.php
Normal file
66
modules/mining-checker/src/Domain/SeedImporter.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
|
||||
final class SeedImporter
|
||||
{
|
||||
private MiningRepository $repository;
|
||||
|
||||
public function __construct(MiningRepository $repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
public function import(string $projectKey): array
|
||||
{
|
||||
$seedProjectKey = SeedData::projectKey();
|
||||
if ($projectKey !== $seedProjectKey) {
|
||||
return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.'];
|
||||
}
|
||||
|
||||
$this->repository->ensureProject($projectKey, SeedData::projectName());
|
||||
foreach (SeedData::currencies() as $currency) {
|
||||
$this->repository->saveCurrency($currency);
|
||||
}
|
||||
$this->repository->saveSettings($projectKey, SeedData::settings());
|
||||
|
||||
$insertedMeasurements = 0;
|
||||
foreach (SeedData::measurements() as $measurement) {
|
||||
try {
|
||||
$this->repository->createMeasurement($projectKey, array_merge([
|
||||
'image_path' => null,
|
||||
'ocr_raw_text' => null,
|
||||
'ocr_confidence' => null,
|
||||
'ocr_flags' => null,
|
||||
], $measurement));
|
||||
$insertedMeasurements++;
|
||||
} catch (\Throwable $exception) {
|
||||
// Duplicate seeds are expected on repeated imports.
|
||||
}
|
||||
}
|
||||
|
||||
$targetCount = 0;
|
||||
foreach (SeedData::targets() as $target) {
|
||||
$this->repository->saveTarget($projectKey, $target);
|
||||
$targetCount++;
|
||||
}
|
||||
|
||||
$dashboardCount = 0;
|
||||
foreach (SeedData::dashboards() as $dashboard) {
|
||||
$this->repository->saveDashboard($projectKey, $dashboard);
|
||||
$dashboardCount++;
|
||||
}
|
||||
|
||||
return [
|
||||
'project_key' => $projectKey,
|
||||
'imported_measurements' => $insertedMeasurements,
|
||||
'historical_rows_total' => count(SeedData::measurements()),
|
||||
'targets_synced' => $targetCount,
|
||||
'dashboards_synced' => $dashboardCount,
|
||||
'currencies_synced' => count(SeedData::currencies()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
use App\Database as AppDatabase;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
use PDO;
|
||||
|
||||
final class ConnectionFactory
|
||||
{
|
||||
public static function make(ModuleConfig $config): PDO
|
||||
{
|
||||
if (!$config->useProjectDatabase()) {
|
||||
throw new ApiException('Mining-Checker erwartet aktuell die Projekt-Datenbank. Eigene Modul-Datenbanken sind hier noch nicht implementiert.', 500);
|
||||
}
|
||||
|
||||
$dbConfig = app()->config()->dbConfig;
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
|
||||
}
|
||||
|
||||
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
|
||||
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:' )) {
|
||||
throw new ApiException(
|
||||
'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle in config/db_settings_basic.php den Driver auf mysql oder pgsql.',
|
||||
500,
|
||||
['driver' => $dbConfig['driver'] ?? 'unknown']
|
||||
);
|
||||
}
|
||||
|
||||
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||
return AppDatabase::connectFromConfig($dbConfig);
|
||||
}
|
||||
|
||||
return AppDatabase::createFromArray($dbConfig);
|
||||
}
|
||||
}
|
||||
1400
modules/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
1400
modules/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
File diff suppressed because it is too large
Load Diff
66
modules/mining-checker/src/Infrastructure/ModuleConfig.php
Normal file
66
modules/mining-checker/src/Infrastructure/ModuleConfig.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
final class ModuleConfig
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public static function load(string $moduleBasePath): self
|
||||
{
|
||||
$config = require $moduleBasePath . '/config/module.php';
|
||||
return new self(is_array($config) ? $config : []);
|
||||
}
|
||||
|
||||
public function defaultProjectKey(): string
|
||||
{
|
||||
return (string) ($this->config['default_project_key'] ?? 'doge-main');
|
||||
}
|
||||
|
||||
public function useProjectDatabase(): bool
|
||||
{
|
||||
return (bool) ($this->config['use_project_database'] ?? true);
|
||||
}
|
||||
|
||||
public function tablePrefix(): string
|
||||
{
|
||||
return (string) ($this->config['table_prefix'] ?? 'miningcheck_');
|
||||
}
|
||||
|
||||
public function uploadsDir(): string
|
||||
{
|
||||
return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir());
|
||||
}
|
||||
|
||||
public function uploadsPublicPrefix(): string
|
||||
{
|
||||
return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/');
|
||||
}
|
||||
|
||||
public function ocr(): array
|
||||
{
|
||||
return (array) ($this->config['ocr'] ?? []);
|
||||
}
|
||||
|
||||
public function fx(): array
|
||||
{
|
||||
return (array) ($this->config['fx'] ?? []);
|
||||
}
|
||||
|
||||
public function debug(): array
|
||||
{
|
||||
return (array) ($this->config['debug'] ?? []);
|
||||
}
|
||||
|
||||
public function debugDir(): string
|
||||
{
|
||||
$debug = $this->debug();
|
||||
return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug'));
|
||||
}
|
||||
}
|
||||
1265
modules/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
1265
modules/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
File diff suppressed because it is too large
Load Diff
29
modules/mining-checker/src/Support/ApiException.php
Normal file
29
modules/mining-checker/src/Support/ApiException.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ApiException extends RuntimeException
|
||||
{
|
||||
private int $statusCode;
|
||||
private array $context;
|
||||
|
||||
public function __construct(string $message, int $statusCode = 400, array $context = [])
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->statusCode = $statusCode;
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function context(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
35
modules/mining-checker/src/Support/DebugState.php
Normal file
35
modules/mining-checker/src/Support/DebugState.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugState
|
||||
{
|
||||
private static array $trace = [];
|
||||
private static ?string $latestFilePath = null;
|
||||
|
||||
public static function replace(array $trace): void
|
||||
{
|
||||
self::$trace = $trace;
|
||||
}
|
||||
|
||||
public static function export(): array
|
||||
{
|
||||
return self::$trace;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$trace = [];
|
||||
}
|
||||
|
||||
public static function setLatestFilePath(?string $filePath): void
|
||||
{
|
||||
self::$latestFilePath = $filePath;
|
||||
}
|
||||
|
||||
public static function latestFilePath(): ?string
|
||||
{
|
||||
return self::$latestFilePath;
|
||||
}
|
||||
}
|
||||
60
modules/mining-checker/src/Support/DebugTrace.php
Normal file
60
modules/mining-checker/src/Support/DebugTrace.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugTrace
|
||||
{
|
||||
private bool $enabled;
|
||||
private array $entries = [];
|
||||
private ?string $filePath;
|
||||
|
||||
public function __construct(bool $enabled = false, ?string $filePath = null)
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
$this->filePath = $enabled ? $filePath : null;
|
||||
DebugState::replace([]);
|
||||
if ($this->enabled && $this->filePath !== null) {
|
||||
$this->persist();
|
||||
}
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function add(string $event, array $context = []): void
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->entries[] = [
|
||||
'time' => date('c'),
|
||||
'event' => $event,
|
||||
'context' => $context,
|
||||
];
|
||||
DebugState::replace($this->entries);
|
||||
$this->persist();
|
||||
}
|
||||
|
||||
public function export(): array
|
||||
{
|
||||
return $this->enabled ? $this->entries : [];
|
||||
}
|
||||
|
||||
private function persist(): void
|
||||
{
|
||||
if (!$this->enabled || $this->filePath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($this->filePath);
|
||||
if (!is_dir($directory)) {
|
||||
@mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
@file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
27
modules/mining-checker/src/Support/Http.php
Normal file
27
modules/mining-checker/src/Support/Http.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class Http
|
||||
{
|
||||
public static function json(array $payload, int $statusCode = 200): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function input(): array
|
||||
{
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw ?: '[]', true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
return $_POST;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user