From 3d2b566bf26675d3ea2c589eb4be9b88adee9985 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Fri, 14 Aug 2026 01:51:48 +0200 Subject: [PATCH] ysdsad --- custom/apps/mining-checker/assets/js/app.js | 225 +++++++++++++++--- .../apps/mining-checker/sql/schema.mysql.sql | 4 + .../apps/mining-checker/sql/schema.pgsql.sql | 4 + custom/apps/mining-checker/sql/schema.sql | 4 + custom/apps/mining-checker/src/Api/Router.php | 4 + .../src/Domain/AnalyticsService.php | 88 +++++-- .../src/Infrastructure/MiningRepository.php | 67 +++++- .../src/Infrastructure/SchemaManager.php | 64 +++++ 8 files changed, 391 insertions(+), 69 deletions(-) diff --git a/custom/apps/mining-checker/assets/js/app.js b/custom/apps/mining-checker/assets/js/app.js index 26c128f3..41fe6d85 100644 --- a/custom/apps/mining-checker/assets/js/app.js +++ b/custom/apps/mining-checker/assets/js/app.js @@ -674,9 +674,20 @@ } function SimpleChart(props) { + const [hoveredPoint, setHoveredPoint] = useState(null); + const touchLike = typeof window !== 'undefined' + && typeof window.matchMedia === 'function' + && window.matchMedia('(hover: none), (pointer: coarse)').matches; + function pointTooltip(seriesLabel, point) { const label = seriesLabel ? `${seriesLabel} · ` : ''; - return `${label}${String(point.x)}: ${fmtNumber(point.y, 6)}`; + return `${label}${String(point.x)}: ${point.formatted || fmtNumber(point.y, 6)}`; + } + + function axisLabel(axis) { + return axis === 'right' + ? (props.rightAxisLabel || 'Rechts') + : (props.leftAxisLabel || props.yLabel || 'Wert'); } const series = Array.isArray(props.series) @@ -685,18 +696,36 @@ key: item && item.key ? String(item.key) : `series-${index}`, label: item && item.label ? String(item.label) : `Serie ${index + 1}`, color: item && item.color ? String(item.color) : ['#60a5fa', '#2dd4bf', '#f59e0b', '#f472b6'][index % 4], + axis: item && item.axis === 'right' ? 'right' : 'left', + formatter: item && typeof item.formatter === 'function' ? item.formatter : null, data: Array.isArray(item && item.data) - ? item.data.filter((point) => point && point.y !== null && point.y !== undefined) + ? item.data + .filter((point) => point && point.y !== null && point.y !== undefined) + .map((point) => ({ + ...point, + formatted: item && typeof item.formatter === 'function' + ? item.formatter(Number(point.y), point) + : (point.formatted || fmtNumber(point.y, 6)), + })) : [], })) .filter((item) => item.data.length > 0) : []; - const points = Array.isArray(props.data) ? props.data.filter((point) => point && point.y !== null && point.y !== undefined) : []; + const points = Array.isArray(props.data) + ? props.data + .filter((point) => point && point.y !== null && point.y !== undefined) + .map((point) => ({ + ...point, + formatted: point.formatted || fmtNumber(point.y, 6), + })) + : []; const isMultiSeries = series.length > 0; const activeSeries = isMultiSeries ? series : [{ key: 'default', label: props.yLabel || 'Wert', color: props.type === 'area' ? '#2dd4bf' : '#60a5fa', + axis: 'left', + formatter: null, data: points, }]; const allPoints = activeSeries.flatMap((item) => item.data); @@ -725,15 +754,32 @@ const width = 640; const height = 220; const padding = 24; - const values = allPoints.map((point) => Number(point.y)); - const minY = Math.min.apply(null, values); - const maxY = Math.max.apply(null, values); - const range = maxY - minY || 1; + const axisStats = activeSeries.reduce((stats, item) => { + const values = item.data.map((point) => Number(point.y)).filter((value) => Number.isFinite(value)); + if (!values.length) { + return stats; + } + + const axis = item.axis === 'right' ? 'right' : 'left'; + const min = Math.min.apply(null, values); + const max = Math.max.apply(null, values); + const existing = stats[axis]; + stats[axis] = existing + ? { + min: Math.min(existing.min, min), + max: Math.max(existing.max, max), + } + : { min, max }; + return stats; + }, { left: null, right: null }); const seriesCoords = activeSeries.map((item) => { + const axis = item.axis === 'right' ? 'right' : 'left'; + const axisMeta = axisStats[axis] || { min: 0, max: 1 }; + const range = axisMeta.max - axisMeta.min || 1; const stepX = item.data.length > 1 ? (width - padding * 2) / (item.data.length - 1) : 0; const coords = item.data.map((point, index) => { const x = padding + stepX * index; - const y = height - padding - ((Number(point.y) - minY) / range) * (height - padding * 2); + const y = height - padding - ((Number(point.y) - axisMeta.min) / range) * (height - padding * 2); return [x, y]; }); @@ -747,18 +793,32 @@ : '', }; }); + const leftAxis = axisStats.left || axisStats.right || { min: 0, max: 1 }; + const rightAxis = axisStats.right; - return h('div', { className: 'mc-chart space-y-3' }, [ + return h('div', { className: 'mc-chart space-y-3', style: { position: 'relative' } }, [ h('div', { key: 'meta', className: 'mc-flex-split mc-kicker' }, [ - h('span', { key: 'min' }, 'Min ' + fmtNumber(minY, 4)), - h('span', { key: 'max' }, 'Max ' + fmtNumber(maxY, 4)), + h('span', { key: 'left' }, `${axisLabel('left')} · Min ${fmtNumber(leftAxis.min, 4)} · Max ${fmtNumber(leftAxis.max, 4)}`), + rightAxis + ? h('span', { key: 'right' }, `${axisLabel('right')} · Min ${fmtNumber(rightAxis.min, 4)} · Max ${fmtNumber(rightAxis.max, 4)}`) + : null, ]), + hoveredPoint + ? h('div', { key: 'hover', className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-kicker', style: { color: hoveredPoint.color } }, hoveredPoint.label), + h('div', { key: 'value' }, `${hoveredPoint.x}: ${hoveredPoint.value}`), + ]) + : null, h('svg', { key: 'svg', viewBox: `0 0 ${width} ${height}`, className: 'overflow-visible' }, [ h('g', { key: 'grid', stroke: 'rgba(255,255,255,0.08)' }, [ h('line', { key: 'top', x1: padding, x2: width - padding, y1: padding, y2: padding }), h('line', { key: 'mid', x1: padding, x2: width - padding, y1: height / 2, y2: height / 2 }), h('line', { key: 'base', x1: padding, x2: width - padding, y1: height - padding, y2: height - padding }), ]), + h('g', { key: 'axis-labels', fill: 'rgba(148,163,184,0.95)', fontSize: 10 }, [ + h('text', { key: 'left-top', x: padding, y: 14, textAnchor: 'start' }, axisLabel('left')), + rightAxis ? h('text', { key: 'right-top', x: width - padding, y: 14, textAnchor: 'end' }, axisLabel('right')) : null, + ]), props.type === 'bar' && !isMultiSeries ? h('g', { key: 'bars' }, seriesCoords[0].coords.map((coord, index) => { const barWidth = Math.max(10, (((width - padding * 2) / Math.max(seriesCoords[0].coords.length - 1, 1)) * 0.6) || 24); @@ -770,6 +830,19 @@ height: height - padding - coord[1], rx: 8, fill: 'rgba(59, 130, 246, 0.75)', + onMouseEnter: () => setHoveredPoint({ + label: seriesCoords[0].label, + x: String(seriesCoords[0].data[index].x), + value: seriesCoords[0].data[index].formatted, + color: seriesCoords[0].color, + }), + onMouseLeave: () => !touchLike && setHoveredPoint(null), + onClick: () => setHoveredPoint({ + label: seriesCoords[0].label, + x: String(seriesCoords[0].data[index].x), + value: seriesCoords[0].data[index].formatted, + color: seriesCoords[0].color, + }), }, [ h('title', { key: 'title' }, pointTooltip(seriesCoords[0].label, seriesCoords[0].data[index])), ]); @@ -796,10 +869,24 @@ key: `${item.key}-${pointIndex}`, cx: coord[0], cy: coord[1], - r: 4, + r: 5, fill: '#f8fafc', stroke: item.color, strokeWidth: 2, + style: { cursor: 'pointer' }, + onMouseEnter: () => setHoveredPoint({ + label: item.label, + x: String(item.data[pointIndex].x), + value: item.data[pointIndex].formatted, + color: item.color, + }), + onMouseLeave: () => !touchLike && setHoveredPoint(null), + onClick: () => setHoveredPoint({ + label: item.label, + x: String(item.data[pointIndex].x), + value: item.data[pointIndex].formatted, + color: item.color, + }), }, [ h('title', { key: 'title' }, pointTooltip(item.label, item.data[pointIndex])), ])))); @@ -1040,6 +1127,7 @@ const currentWalletSnapshots = Array.isArray(payload?.wallet_snapshots) ? payload.wallet_snapshots : []; const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : []; const currentMiningCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase(); + const overviewPerDayLabel = `${currentMiningCurrency} pro Tag`; const latestMeasurementDate = latest && latest.measured_at ? parseStoredUtcDate(latest.measured_at) : null; const latestMeasurementAgeMs = latestMeasurementDate ? (Date.now() - latestMeasurementDate.getTime()) : null; const canTransferAll = !!latest @@ -1789,32 +1877,87 @@ const overviewWindowDays = Number(payload?.bootstrap_meta?.overview_window_days || 15); const overviewRows = recentMeasurementWindow(measurements, overviewWindowDays); const performanceByDay = new Map(); + const pricingByDay = new Map(); overviewRows.forEach((row) => { const rate = Number(row.doge_per_day_interval); + const hashrate = Number(row.active_hashrate_mh); const dayKey = String(row.measured_date || '').trim() || String(row.measured_at || '').slice(0, 10); - if (!dayKey || !Number.isFinite(rate) || rate < 0) { + if (dayKey) { + if (Number.isFinite(rate) && rate >= 0) { + const existing = performanceByDay.get(dayKey) || { rateSum: 0, rateCount: 0, hashrateSum: 0, hashrateCount: 0, label: fmtDate(dayKey) }; + existing.rateSum += rate; + existing.rateCount += 1; + if (Number.isFinite(hashrate) && hashrate >= 0) { + existing.hashrateSum += hashrate; + existing.hashrateCount += 1; + } + performanceByDay.set(dayKey, existing); + } else if (Number.isFinite(hashrate) && hashrate >= 0) { + const existing = performanceByDay.get(dayKey) || { rateSum: 0, rateCount: 0, hashrateSum: 0, hashrateCount: 0, label: fmtDate(dayKey) }; + existing.hashrateSum += hashrate; + existing.hashrateCount += 1; + performanceByDay.set(dayKey, existing); + } + } + + const effectivePrice = Number(row.effective_price_per_coin); + const convertedPrice = Number.isFinite(effectivePrice) + ? convertMeasurementMoney(row, effectivePrice, reportCurrency) + : null; + if (!dayKey || !Number.isFinite(Number(convertedPrice))) { return; } - const existing = performanceByDay.get(dayKey) || { sum: 0, count: 0, label: fmtDate(dayKey) }; - existing.sum += rate; - existing.count += 1; - performanceByDay.set(dayKey, existing); + const priceEntry = pricingByDay.get(dayKey) || { sum: 0, count: 0, label: fmtDate(dayKey) }; + priceEntry.sum += Number(convertedPrice); + priceEntry.count += 1; + pricingByDay.set(dayKey, priceEntry); }); return { - performance: Array.from(performanceByDay.entries()) + performance: { + series: [ + { + key: 'doge-per-day', + label: overviewPerDayLabel, + color: '#2dd4bf', + axis: 'left', + formatter: (value) => `${fmtNumber(value, 4)} ${currentMiningCurrency}`, + data: Array.from(performanceByDay.entries()) + .sort((left, right) => String(left[0]).localeCompare(String(right[0]))) + .map(([, entry]) => ({ + x: entry.label, + y: entry.rateCount > 0 ? (entry.rateSum / entry.rateCount) : null, + })) + .filter((point) => Number.isFinite(Number(point.y))), + }, + { + key: 'hashrate', + label: 'Aktive Hashrate', + color: '#60a5fa', + axis: 'right', + formatter: (value) => `${fmtNumber(value, 4)} MH/s`, + data: Array.from(performanceByDay.entries()) + .sort((left, right) => String(left[0]).localeCompare(String(right[0]))) + .map(([, entry]) => ({ + x: entry.label, + y: entry.hashrateCount > 0 ? (entry.hashrateSum / entry.hashrateCount) : null, + })) + .filter((point) => Number.isFinite(Number(point.y))), + }, + ], + }, + pricing: Array.from(pricingByDay.entries()) .sort((left, right) => String(left[0]).localeCompare(String(right[0]))) .map(([, entry]) => ({ x: entry.label, y: entry.count > 0 ? (entry.sum / entry.count) : null, + formatted: entry.count > 0 ? fmtMoney(entry.sum / entry.count, reportCurrency) : null, })) .filter((point) => Number.isFinite(Number(point.y))), - pricing: overviewRows.filter((row) => row.price_per_coin !== null) - .map((row) => ({ x: fmtDate(row.measured_at), y: row.price_per_coin })), }; - }, [currentSettings.crypto_currency, measurements, payload?.bootstrap_meta?.overview_window_days]); + }, [currentMiningCurrency, measurements, overviewPerDayLabel, payload?.bootstrap_meta?.overview_window_days, reportCurrency]); async function saveMeasurement(raw, successMessage) { setSaving(true); @@ -2866,14 +3009,6 @@ value: latest ? fmtNumber(latest.coins_total_visible ?? latest.coins_total, 6) : 'n/a', sub: latest ? `Stand ${fmtDate(latest.measured_at)}` : '', }), - h(StatCard, { - key: 'holdings', - label: `Theoretischer Bestand ${currentCoinCurrency}`, - value: payload?.summary?.payouts ? fmtNumber(holdingsCurrentAsset, 6) : 'n/a', - sub: payload?.summary?.payouts - ? `Wallet-Gegenwert ${fmtNumber(walletBalanceCurrentAsset, 6)} ${currentCoinCurrency}${currentWalletPrimaryCurrency !== currentCoinCurrency ? ` · direkt ${fmtNumber(walletBalanceCurrentAssetDirect, 6)} ${currentCoinCurrency}` : ''} · Miner ${fmtNumber(latest?.coins_total_visible ?? latest?.coins_total, 6)} ${currentCoinCurrency}` - : '', - }), h(StatCard, { key: 'earned-overall', label: `Bisher verdient gesamt ${currentCoinCurrency}`, @@ -2912,17 +3047,24 @@ h(StatCard, { key: 'value', label: 'Aktueller Gegenwert', - value: latestValue !== null ? fmtMoney(latestValue, reportCurrency) : 'n/a', - sub: latestPrice !== null - ? `Kurs ${fmtNumber(latestPrice, 6)} ${reportCurrency}${latest && latest.price_is_fallback ? ' · Fallback aus letztem Kurs' : ''}` - : 'Kein umrechenbarer Kurs am letzten Punkt', + value: totalHoldingsValue !== null ? fmtMoney(totalHoldingsValue, reportCurrency) : 'n/a', + sub: [ + holdingsCurrentAsset !== null && holdingsCurrentAsset !== undefined + ? `Gesamtbestand ${fmtNumber(holdingsCurrentAsset, 6)} ${currentCoinCurrency}` + : null, + latestValue !== null ? `Miner ${fmtMoney(latestValue, reportCurrency)}` : null, + walletValue !== null ? `Wallet ${fmtMoney(walletValue, reportCurrency)}` : null, + latestPrice !== null + ? `Kurs ${fmtNumber(latestPrice, 6)} ${reportCurrency}${latest && latest.price_is_fallback ? ' · Fallback aus letztem Kurs' : ''}` + : null, + ].filter(Boolean).join(' · ') || 'Kein umrechenbarer Kurs am letzten Punkt', }), h(StatCard, { key: 'profit', label: 'Theoretischer Tagesgewinn', value: dailyProfit !== null ? fmtMoney(dailyProfit, reportCurrency) : 'n/a', sub: dailyCost !== null - ? `Tageskosten ${fmtMoney(dailyCost, reportCurrency)} · Walletwert ${walletValue !== null ? fmtMoney(walletValue, reportCurrency) : 'n/a'}` + ? `Tageskosten ${fmtMoney(dailyCost, reportCurrency)}` : 'Kein aktiver Miner fuer diese Waehrung', }), h(StatCard, { @@ -2934,7 +3076,7 @@ ? `${fmtNumber(breakEvenDaysOverall, 2)} Tage` : (investedCapital === null ? 'Keine Mietbasis' : 'Nicht erreichbar'), sub: investedCapital !== null - ? `${breakEvenEta ? `ETA ${breakEvenEta} · ` : ''}Cash ${fmtMoney(investedCapital, reportCurrency)}${reinvestedCapital !== null ? ` · Reinvest ${fmtMoney(reinvestedCapital, reportCurrency)}` : ''}${totalHoldingsValue !== null ? ` · Bestand ${fmtMoney(totalHoldingsValue, reportCurrency)}` : ''}` + ? `${breakEvenEta ? `ETA ${breakEvenEta} · ` : ''}Cash ${fmtMoney(investedCapital, reportCurrency)}${reinvestedCapital !== null ? ` · Reinvest ${fmtMoney(reinvestedCapital, reportCurrency)}` : ''}` : (breakEvenPrice !== null ? `Break-even-Kurs ${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}` : (investedCapital === null @@ -2943,8 +3085,17 @@ }), ]), h('div', { key: 'charts', className: 'mc-overview-grid' }, [ - panel('Performance-Verlauf', `${perDayLabel}-Raten der letzten 15 Tage als Tagesdurchschnitt ohne negative Ausreißer.`, h(SimpleChart, { type: 'area', data: overviewCharts.performance })), - panel('Kurs-Verlauf', 'Preiswerte der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.pricing })), + panel('Performance-Verlauf', `${perDayLabel}-Raten der letzten 15 Tage als Tagesdurchschnitt ohne negative Ausreißer plus aktive MH/s pro Tag.`, h(SimpleChart, { + type: 'line', + series: overviewCharts.performance.series, + leftAxisLabel: perDayLabel, + rightAxisLabel: 'Hashrate MH/s', + })), + panel('Kurs-Verlauf', `Durchschnittlicher Kurs pro Tag der letzten 15 Tage in ${reportCurrency}.`, h(SimpleChart, { + type: 'line', + data: overviewCharts.pricing, + yLabel: `Kurs ${reportCurrency}`, + })), ]), panel('Zielmonitor', `Rest-${currentCoinCurrency} und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.`, h('div', { className: 'mc-target-grid' }, diff --git a/custom/apps/mining-checker/sql/schema.mysql.sql b/custom/apps/mining-checker/sql/schema.mysql.sql index da79f30b..7c230cc0 100644 --- a/custom/apps/mining-checker/sql/schema.mysql.sql +++ b/custom/apps/mining-checker/sql/schema.mysql.sql @@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', total_cost_amount DECIMAL(20,8) NOT NULL, currency VARCHAR(10) NOT NULL, + daily_cost_amount DECIMAL(20,10) NULL, + daily_cost_currency VARCHAR(10) NULL, note TEXT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -220,6 +222,8 @@ CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners ( usd_reference_amount DECIMAL(20,8) NULL, reference_price_amount DECIMAL(20,8) NULL, reference_price_currency VARCHAR(10) NULL, + daily_cost_amount DECIMAL(20,10) NULL, + daily_cost_currency VARCHAR(10) NULL, auto_renew TINYINT(1) NOT NULL DEFAULT 0, note TEXT, is_active TINYINT(1) NOT NULL DEFAULT 1, diff --git a/custom/apps/mining-checker/sql/schema.pgsql.sql b/custom/apps/mining-checker/sql/schema.pgsql.sql index dccd1d61..4f82c4e7 100644 --- a/custom/apps/mining-checker/sql/schema.pgsql.sql +++ b/custom/apps/mining-checker/sql/schema.pgsql.sql @@ -42,6 +42,8 @@ CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', total_cost_amount NUMERIC(20,8) NOT NULL, currency VARCHAR(10) NOT NULL, + daily_cost_amount NUMERIC(20,10), + daily_cost_currency VARCHAR(10), note TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -237,6 +239,8 @@ CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners ( usd_reference_amount NUMERIC(20,8), reference_price_amount NUMERIC(20,8), reference_price_currency VARCHAR(10), + daily_cost_amount NUMERIC(20,10), + daily_cost_currency VARCHAR(10), auto_renew BOOLEAN NOT NULL DEFAULT FALSE, note TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, diff --git a/custom/apps/mining-checker/sql/schema.sql b/custom/apps/mining-checker/sql/schema.sql index da79f30b..7c230cc0 100644 --- a/custom/apps/mining-checker/sql/schema.sql +++ b/custom/apps/mining-checker/sql/schema.sql @@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', total_cost_amount DECIMAL(20,8) NOT NULL, currency VARCHAR(10) NOT NULL, + daily_cost_amount DECIMAL(20,10) NULL, + daily_cost_currency VARCHAR(10) NULL, note TEXT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -220,6 +222,8 @@ CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners ( usd_reference_amount DECIMAL(20,8) NULL, reference_price_amount DECIMAL(20,8) NULL, reference_price_currency VARCHAR(10) NULL, + daily_cost_amount DECIMAL(20,10) NULL, + daily_cost_currency VARCHAR(10) NULL, auto_renew TINYINT(1) NOT NULL DEFAULT 0, note TEXT, is_active TINYINT(1) NOT NULL DEFAULT 1, diff --git a/custom/apps/mining-checker/src/Api/Router.php b/custom/apps/mining-checker/src/Api/Router.php index c64ad767..418a3d25 100644 --- a/custom/apps/mining-checker/src/Api/Router.php +++ b/custom/apps/mining-checker/src/Api/Router.php @@ -594,6 +594,8 @@ final class Router 'auto_renew' => !empty($plan['auto_renew']) ? 1 : 0, 'total_cost_amount' => $plan['total_cost_amount'], 'currency' => $plan['currency'], + 'daily_cost_amount' => $plan['daily_cost_amount'] ?? null, + 'daily_cost_currency' => $plan['daily_cost_currency'] ?? null, 'note' => $plan['note'] ?? null, 'is_active' => !empty($plan['is_active']) ? 1 : 0, ]); @@ -731,6 +733,8 @@ final class Router 'usd_reference_amount' => $miner['usd_reference_amount'] ?? null, 'reference_price_amount' => $miner['reference_price_amount'] ?? null, 'reference_price_currency' => $miner['reference_price_currency'] ?? null, + 'daily_cost_amount' => $miner['daily_cost_amount'] ?? null, + 'daily_cost_currency' => $miner['daily_cost_currency'] ?? null, 'auto_renew' => !empty($miner['auto_renew']) ? 1 : 0, 'note' => $miner['note'] ?? null, 'is_active' => !empty($miner['is_active']) ? 1 : 0, diff --git a/custom/apps/mining-checker/src/Domain/AnalyticsService.php b/custom/apps/mining-checker/src/Domain/AnalyticsService.php index 83e783c1..28092be7 100644 --- a/custom/apps/mining-checker/src/Domain/AnalyticsService.php +++ b/custom/apps/mining-checker/src/Domain/AnalyticsService.php @@ -209,7 +209,7 @@ final class AnalyticsService } } - $effectiveDailyCost = $includeFullDetail ? $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency, $row) : null; + $effectiveDailyCost = $includeFullDetail ? $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency, $row, $purchasedMiners) : null; $currentValue = ($includeFullDetail && $price !== null) ? $visibleCoinsTotal * $price : null; $currentValueEffective = ($includeFullDetail && $price !== null) ? $effectiveCoinsTotal * $price : null; $theoreticalDailyRevenue = ($includeFullDetail && $price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null; @@ -636,7 +636,7 @@ final class AnalyticsService return $value === null ? null : round($value, $precision); } - private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency, ?array $fxContext = null): ?float + private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency, ?array $fxContext = null, array $purchasedMiners = []): ?float { if ($currency === null) { return null; @@ -644,31 +644,56 @@ final class AnalyticsService $dailyTotal = 0.0; $matched = false; + + $entries = []; foreach ($costPlans as $plan) { - if (empty($plan['is_active'])) { + $entries[] = [ + 'entry' => $plan, + 'start_field' => 'starts_at', + ]; + } + foreach ($purchasedMiners as $miner) { + $entries[] = [ + 'entry' => $miner, + 'start_field' => 'purchased_at', + ]; + } + + foreach ($entries as $wrappedEntry) { + $entry = is_array($wrappedEntry['entry'] ?? null) ? $wrappedEntry['entry'] : []; + $startField = (string) ($wrappedEntry['start_field'] ?? 'starts_at'); + if (empty($entry['is_active'])) { continue; } - $startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? '')); - $runtimeMonths = (int) ($plan['runtime_months'] ?? 0); + $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); + $runtimeMonths = (int) ($entry['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) { + if (!$this->entryIsCovered($entry, $measurementTs, $startField)) { continue; } - $planDailyCost = (float) $plan['total_cost_amount'] / $runtimeDays; - $convertedDailyCost = $this->convertAmount( - $planDailyCost, - (string) ($plan['currency'] ?? ''), - $currency, - $fxContext - ); + $storedDailyCost = is_numeric($entry['daily_cost_amount'] ?? null) ? (float) $entry['daily_cost_amount'] : null; + $entryCurrency = strtoupper(trim((string) ($entry['daily_cost_currency'] ?? $entry['currency'] ?? ''))); + if ($entryCurrency === '') { + continue; + } + + $planDailyCost = $storedDailyCost; + if ($planDailyCost === null || $planDailyCost <= 0) { + $amount = is_numeric($entry['total_cost_amount'] ?? null) ? (float) $entry['total_cost_amount'] : null; + if ($amount === null || $amount <= 0) { + continue; + } + + $runtimeDays = $runtimeMonths * 30.4375; + $planDailyCost = $amount / $runtimeDays; + } + + $convertedDailyCost = $this->convertAmount($planDailyCost, $entryCurrency, $currency, $fxContext); if ($convertedDailyCost === null) { continue; } @@ -996,7 +1021,7 @@ final class AnalyticsService $runtimeDays = $runtimeMonths * 30.4375; $endTs = (int) round($startTs + ($runtimeDays * 86400)); - return !empty($entry['auto_renew']) || $checkTs <= $endTs; + return $this->entryAutoRenewEnabled($entry) || $checkTs <= $endTs; } private function normalizeHashrateMh(mixed $value, mixed $unit): float @@ -1018,6 +1043,29 @@ final class AnalyticsService return array_map(fn (mixed $entry): mixed => is_array($entry) ? $this->normalizeLegacyHashrateEntry($entry) : $entry, $entries); } + private function entryAutoRenewEnabled(array $entry): bool + { + if ($this->entryIsCryptoPaid($entry)) { + return false; + } + + return !empty($entry['auto_renew']); + } + + private function entryIsCryptoPaid(array $entry): bool + { + $paymentType = strtolower(trim((string) ($entry['payment_type'] ?? ''))); + if ($paymentType === 'crypto') { + return true; + } + + $currency = strtoupper(trim((string) ($entry['currency'] ?? ''))); + return in_array($currency, [ + 'ADA', 'ARB', 'AVAX', 'BNB', 'BTC', 'CTC', 'DAI', 'DOGE', 'DOT', 'ETH', + 'HSH', 'LINK', 'LTC', 'MATIC', 'SOL', 'TRX', 'USDC', 'USDT', 'XMR', 'XRP', + ], true); + } + private function normalizeLegacyHashrateEntry(array $entry): array { $label = trim((string) ($entry['label'] ?? '')); @@ -1522,7 +1570,7 @@ final class AnalyticsService return 0; } - if (!empty($entry['auto_renew']) || $runtimeMonths <= 0) { + if ($this->entryAutoRenewEnabled($entry) || $runtimeMonths <= 0) { return $days - $startIndex; } @@ -1807,7 +1855,7 @@ final class AnalyticsService private function entryFundingSource(array $entry): string { - return !empty($entry['auto_renew']) ? 'cash' : 'reinvest'; + return $this->entryIsCryptoPaid($entry) ? 'reinvest' : 'cash'; } private function investmentBasisAmount(array $entry): ?float @@ -1933,7 +1981,7 @@ final class AnalyticsService private function entryCoverageEndTimestamp(array $entry, string $startField = 'starts_at'): ?int { - if (!empty($entry['auto_renew'])) { + if ($this->entryAutoRenewEnabled($entry)) { return null; } diff --git a/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php b/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php index 66752b15..d7944ced 100644 --- a/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php +++ b/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php @@ -239,10 +239,12 @@ final class MiningRepository $stmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('cost_plans') . ' ( project_key, owner_sub, label, starts_at, runtime_months, mining_speed_value, mining_speed_unit, - bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, note, is_active + bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, + daily_cost_amount, daily_cost_currency, note, is_active ) VALUES ( :project_key, :owner_sub, :label, :starts_at, :runtime_months, :mining_speed_value, :mining_speed_unit, - :bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, :note, :is_active + :bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, + :daily_cost_amount, :daily_cost_currency, :note, :is_active ) RETURNING *' ); @@ -253,10 +255,12 @@ final class MiningRepository $stmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('cost_plans') . ' ( project_key, owner_sub, label, starts_at, runtime_months, mining_speed_value, mining_speed_unit, - bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, note, is_active + bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, + daily_cost_amount, daily_cost_currency, note, is_active ) VALUES ( :project_key, :owner_sub, :label, :starts_at, :runtime_months, :mining_speed_value, :mining_speed_unit, - :bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, :note, :is_active + :bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, + :daily_cost_amount, :daily_cost_currency, :note, :is_active )' ); $stmt->execute($this->normalizeInsertPayload($projectKey, $payload)); @@ -1001,11 +1005,13 @@ final class MiningRepository 'INSERT INTO ' . $this->table('purchased_miners') . ' ( project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, - total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, + daily_cost_amount, daily_cost_currency, auto_renew, note, is_active ) VALUES ( :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, - :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, + :daily_cost_amount, :daily_cost_currency, :auto_renew, :note, :is_active ) RETURNING *' ); @@ -1017,11 +1023,13 @@ final class MiningRepository 'INSERT INTO ' . $this->table('purchased_miners') . ' ( project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, - total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, + daily_cost_amount, daily_cost_currency, auto_renew, note, is_active ) VALUES ( :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, - :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, + :daily_cost_amount, :daily_cost_currency, :auto_renew, :note, :is_active )' ); $stmt->execute($this->normalizePurchasedPayload($projectKey, $offerId, $payload)); @@ -1049,6 +1057,8 @@ final class MiningRepository 'usd_reference_amount' => $payload['usd_reference_amount'] ?? null, 'reference_price_amount' => $payload['reference_price_amount'] ?? null, 'reference_price_currency' => $payload['reference_price_currency'] ?? null, + 'daily_cost_amount' => $payload['daily_cost_amount'] ?? $this->deriveDailyCostAmount($payload['total_cost_amount'] ?? null, $payload['runtime_months'] ?? null), + 'daily_cost_currency' => $payload['daily_cost_currency'] ?? ($payload['currency'] ?? null), 'auto_renew' => $payload['auto_renew'] ?? 0, 'note' => $payload['note'] ?? null, 'is_active' => $payload['is_active'] ?? 1, @@ -1059,11 +1069,13 @@ final class MiningRepository 'INSERT INTO ' . $this->table('purchased_miners') . ' ( project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, - total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, + daily_cost_amount, daily_cost_currency, auto_renew, note, is_active ) VALUES ( :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, - :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, + :daily_cost_amount, :daily_cost_currency, :auto_renew, :note, :is_active ) RETURNING *' ); @@ -1075,11 +1087,13 @@ final class MiningRepository 'INSERT INTO ' . $this->table('purchased_miners') . ' ( project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, - total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, + daily_cost_amount, daily_cost_currency, auto_renew, note, is_active ) VALUES ( :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, - :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, + :daily_cost_amount, :daily_cost_currency, :auto_renew, :note, :is_active )' ); $stmt->execute($params); @@ -1567,6 +1581,9 @@ final class MiningRepository private function normalizeInsertPayload(string $projectKey, array $payload): array { + $dailyCostAmount = $payload['daily_cost_amount'] ?? $this->deriveDailyCostAmount($payload['total_cost_amount'] ?? null, $payload['runtime_months'] ?? null); + $dailyCostCurrency = $payload['daily_cost_currency'] ?? ($payload['currency'] ?? null); + return [ 'project_key' => $projectKey, 'owner_sub' => $this->ownerSub, @@ -1582,6 +1599,8 @@ final class MiningRepository 'payment_type' => $payload['payment_type'] ?? 'fiat', 'total_cost_amount' => $payload['total_cost_amount'], 'currency' => $payload['currency'], + 'daily_cost_amount' => $dailyCostAmount, + 'daily_cost_currency' => $dailyCostCurrency, 'note' => $payload['note'], 'is_active' => $payload['is_active'], ]; @@ -1608,6 +1627,9 @@ final class MiningRepository private function normalizePurchasedPayload(string $projectKey, ?int $offerId, array $payload): array { + $dailyCostAmount = $payload['daily_cost_amount'] ?? $this->deriveDailyCostAmount($payload['total_cost_amount'] ?? null, $payload['runtime_months'] ?? null); + $dailyCostCurrency = $payload['daily_cost_currency'] ?? ($payload['currency'] ?? null); + return [ 'project_key' => $projectKey, 'owner_sub' => $this->ownerSub, @@ -1624,12 +1646,33 @@ final class MiningRepository 'usd_reference_amount' => $payload['usd_reference_amount'], 'reference_price_amount' => $payload['reference_price_amount'] ?? null, 'reference_price_currency' => $payload['reference_price_currency'] ?? null, + 'daily_cost_amount' => $dailyCostAmount, + 'daily_cost_currency' => $dailyCostCurrency, 'auto_renew' => $payload['auto_renew'] ?? 0, 'note' => $payload['note'], 'is_active' => $payload['is_active'], ]; } + private function deriveDailyCostAmount(mixed $totalCostAmount, mixed $runtimeMonths): ?float + { + if (!is_numeric($totalCostAmount) || !is_numeric($runtimeMonths)) { + return null; + } + + $months = (float) $runtimeMonths; + if ($months <= 0) { + return null; + } + + $runtimeDays = $months * 30.4375; + if ($runtimeDays <= 0) { + return null; + } + + return round(((float) $totalCostAmount) / $runtimeDays, 10); + } + private function normalizeRows(array $rows): array { return array_map(fn (array $row): array => $this->normalizeRow($row), $rows); diff --git a/custom/apps/mining-checker/src/Infrastructure/SchemaManager.php b/custom/apps/mining-checker/src/Infrastructure/SchemaManager.php index af7be65f..a1a81fd3 100644 --- a/custom/apps/mining-checker/src/Infrastructure/SchemaManager.php +++ b/custom/apps/mining-checker/src/Infrastructure/SchemaManager.php @@ -178,6 +178,19 @@ final class SchemaManager $applied[] = 'cost_plan_columns'; } } + if ( + ($this->tableExists($this->prefix . 'cost_plans') && ( + !$this->columnExists($this->prefix . 'cost_plans', 'daily_cost_amount') || + !$this->columnExists($this->prefix . 'cost_plans', 'daily_cost_currency') + )) || + ($this->tableExists($this->prefix . 'purchased_miners') && ( + !$this->columnExists($this->prefix . 'purchased_miners', 'daily_cost_amount') || + !$this->columnExists($this->prefix . 'purchased_miners', 'daily_cost_currency') + )) + ) { + $this->upgradeServerDailyCostColumns(); + $applied[] = 'server_daily_cost_columns'; + } $settingsColumns = $this->existingColumns($this->prefix . 'settings', ['preferred_currencies', 'report_currency', 'crypto_currency', 'display_timezone', 'fx_max_age_hours', 'module_theme_mode', 'module_theme_accent']); if (!in_array('preferred_currencies', $settingsColumns, true) || !in_array('report_currency', $settingsColumns, true) || !in_array('crypto_currency', $settingsColumns, true) || !in_array('display_timezone', $settingsColumns, true) || !in_array('fx_max_age_hours', $settingsColumns, true) || !in_array('module_theme_mode', $settingsColumns, true) || !in_array('module_theme_accent', $settingsColumns, true)) { @@ -665,6 +678,19 @@ final class SchemaManager } } + foreach ([$this->prefix . 'cost_plans', $this->prefix . 'purchased_miners'] as $serverTable) { + if ( + in_array($serverTable, $presentTables, true) && + ( + !$this->columnExists($serverTable, 'daily_cost_amount') || + !$this->columnExists($serverTable, 'daily_cost_currency') + ) + ) { + $upgrades[] = 'server_daily_cost_columns'; + break; + } + } + if ($this->tableExists($this->prefix . 'settings')) { $requiredSettingsColumns = ['preferred_currencies', 'report_currency', 'crypto_currency', 'display_timezone', 'fx_max_age_hours', 'module_theme_mode', 'module_theme_accent']; $existingSettingsColumns = $this->existingColumns($this->prefix . 'settings', $requiredSettingsColumns); @@ -1252,6 +1278,8 @@ final class SchemaManager usd_reference_amount NUMERIC(20,8), reference_price_amount NUMERIC(20,8), reference_price_currency VARCHAR(10), + daily_cost_amount NUMERIC(20,10), + daily_cost_currency VARCHAR(10), auto_renew BOOLEAN NOT NULL DEFAULT FALSE, note TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, @@ -1278,6 +1306,8 @@ final class SchemaManager usd_reference_amount DECIMAL(20,8) NULL, reference_price_amount DECIMAL(20,8) NULL, reference_price_currency VARCHAR(10) NULL, + daily_cost_amount DECIMAL(20,10) NULL, + daily_cost_currency VARCHAR(10) NULL, auto_renew TINYINT(1) NOT NULL DEFAULT 0, note TEXT, is_active TINYINT(1) NOT NULL DEFAULT 1, @@ -1451,6 +1481,40 @@ final class SchemaManager } } + private function upgradeServerDailyCostColumns(): void + { + foreach ([$this->prefix . 'cost_plans', $this->prefix . 'purchased_miners'] as $table) { + if (!$this->tableExists($table)) { + continue; + } + + $statements = $this->driver === 'pgsql' + ? [ + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS daily_cost_amount NUMERIC(20,10)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS daily_cost_currency VARCHAR(10)', + 'UPDATE ' . $table . ' SET daily_cost_amount = ROUND((total_cost_amount / NULLIF(runtime_months * 30.4375, 0))::numeric, 10) WHERE daily_cost_amount IS NULL AND total_cost_amount IS NOT NULL AND runtime_months IS NOT NULL AND runtime_months > 0', + 'UPDATE ' . $table . ' SET daily_cost_currency = COALESCE(daily_cost_currency, currency) WHERE currency IS NOT NULL', + ] + : [ + 'ALTER TABLE `' . $table . '` ADD COLUMN daily_cost_amount DECIMAL(20,10) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN daily_cost_currency VARCHAR(10) NULL', + 'UPDATE `' . $table . '` SET daily_cost_amount = ROUND(total_cost_amount / NULLIF(runtime_months * 30.4375, 0), 10) WHERE daily_cost_amount IS NULL AND total_cost_amount IS NOT NULL AND runtime_months IS NOT NULL AND runtime_months > 0', + 'UPDATE `' . $table . '` SET daily_cost_currency = COALESCE(daily_cost_currency, currency) WHERE currency IS NOT NULL', + ]; + + foreach ($statements as $statement) { + try { + $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Server-Tageskosten fehlgeschlagen.'); + } catch (\Throwable $exception) { + if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { + continue; + } + throw $exception; + } + } + } + } + private function upgradeTargetOfferColumn(): void { $table = $this->prefix . 'targets';