From e0afaca57b40132b67591c1d73f5752b6065b6ba Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Mon, 13 Jul 2026 22:48:56 +0200 Subject: [PATCH] qewqwe --- custom/apps/mining-checker/assets/js/app.js | 137 +++++++++++++++--- .../apps/mining-checker/sql/schema.mysql.sql | 12 ++ .../apps/mining-checker/sql/schema.pgsql.sql | 13 ++ custom/apps/mining-checker/sql/schema.sql | 12 ++ custom/apps/mining-checker/src/Api/Router.php | 58 ++++++++ .../src/Domain/AnalyticsService.php | 64 ++++++-- .../src/Infrastructure/MiningRepository.php | 87 +++++++++++ .../src/Infrastructure/SchemaManager.php | 51 +++++++ 8 files changed, 406 insertions(+), 28 deletions(-) diff --git a/custom/apps/mining-checker/assets/js/app.js b/custom/apps/mining-checker/assets/js/app.js index d8b0073a..099010b2 100644 --- a/custom/apps/mining-checker/assets/js/app.js +++ b/custom/apps/mining-checker/assets/js/app.js @@ -477,9 +477,6 @@ daily_cost_currency: 'EUR', report_currency: 'EUR', crypto_currency: 'DOGE', - crypto_base_price_amount: '', - crypto_base_price_currency: 'USD', - fiat_base_price_amount: '', preferred_currencies: ['DOGE', 'USD', 'EUR'], cost_plans: [], currencies: [], @@ -890,6 +887,7 @@ crypto_base_price_currency: 'USD', fiat_base_price_amount: '', }); + const [offerBasisHistory, setOfferBasisHistory] = useState([]); const [previewMinerOffers, setPreviewMinerOffers] = useState([]); const [previewOffersLoading, setPreviewOffersLoading] = useState(false); const [moduleAuthForm, setModuleAuthForm] = useState({ @@ -1029,6 +1027,9 @@ const selectableCurrencies = preferredSelectableCurrencies.length ? preferredSelectableCurrencies : currencies; const evaluatedMinerOffers = Array.isArray(previewMinerOffers) ? previewMinerOffers : []; const selectedOfferType = String(minerOfferFilters.offer_type || 'crypto').toLowerCase() === 'fiat' ? 'fiat' : 'crypto'; + const visibleOfferBasisHistory = (Array.isArray(offerBasisHistory) ? offerBasisHistory : []) + .filter((entry) => String(entry.offer_type || '').toLowerCase() === selectedOfferType) + .slice(0, 6); const hasCryptoOfferBasis = Number(offerPreviewForm.crypto_base_price_amount) > 0; const hasFiatOfferBasis = Number(offerPreviewForm.fiat_base_price_amount) > 0; const filteredMinerOffers = evaluatedMinerOffers.filter((offer) => { @@ -1188,7 +1189,7 @@ } finally { setPreviewOffersLoading(false); } - }, 250); + }, selectedOfferType === 'crypto' ? 1000 : 250); return () => window.clearTimeout(timeoutId); }, [ @@ -1532,8 +1533,24 @@ return; } loadFxHistory(projectKey); + loadOfferBasisHistory(projectKey); }, [activeTab, projectKey]); + useEffect(() => { + if (!Array.isArray(offerBasisHistory) || offerBasisHistory.length === 0) { + return; + } + + const latestCrypto = offerBasisHistory.find((entry) => String(entry.offer_type || '').toLowerCase() === 'crypto') || null; + const latestFiat = offerBasisHistory.find((entry) => String(entry.offer_type || '').toLowerCase() === 'fiat') || null; + + setOfferPreviewForm((current) => ({ + crypto_base_price_amount: current.crypto_base_price_amount || (latestCrypto && latestCrypto.base_price_amount !== null && latestCrypto.base_price_amount !== undefined ? String(latestCrypto.base_price_amount) : ''), + crypto_base_price_currency: current.crypto_base_price_currency || latestCrypto?.base_price_currency || 'USD', + fiat_base_price_amount: current.fiat_base_price_amount || (latestFiat && latestFiat.base_price_amount !== null && latestFiat.base_price_amount !== undefined ? String(latestFiat.base_price_amount) : ''), + })); + }, [offerBasisHistory]); + useEffect(() => { if (activeTab !== 'wallet') { return; @@ -2398,6 +2415,37 @@ } } + async function loadOfferBasisHistory(key) { + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(key)}/offer-basis-history`, { timeoutMs: 6000 }); + setOfferBasisHistory(Array.isArray(result) ? result : []); + } catch (err) { + setOfferBasisHistory([]); + } + } + + async function rememberOfferBasisInput(offerType, amount, currency) { + const numericAmount = Number(amount); + if (!Number.isFinite(numericAmount) || numericAmount <= 0) { + return; + } + + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/offer-basis-history`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + offer_type: offerType, + base_price_amount: numericAmount, + base_price_currency: offerType === 'crypto' ? (currency || 'USD') : 'EUR', + }), + }); + await loadOfferBasisHistory(projectKey); + } catch (err) { + setError(err.message); + } + } + function resetReportCurrencyOverride() { setReportCurrencyOverride(''); setCookie('mining_checker_report_currency', '', 0); @@ -3075,10 +3123,32 @@ [ h('div', { key: 'filters', className: 'mc-filter-grid' }, [ selectedOfferType === 'crypto' - ? inputField('Basispreis fuer 50 kH/s auf 3 Monate', 'number', offerPreviewForm.crypto_base_price_amount, (value) => setOfferPreviewForm({ ...offerPreviewForm, crypto_base_price_amount: value }), '0.000001') - : inputField('Basispreis fuer 100 kH/s auf 3 Monate in EUR', 'number', offerPreviewForm.fiat_base_price_amount, (value) => setOfferPreviewForm({ ...offerPreviewForm, fiat_base_price_amount: value }), '0.000001'), + ? fieldWrapper('Basispreis fuer 50 kH/s auf 3 Monate', h('input', { + className: 'mc-input', + type: 'number', + step: '0.000001', + value: offerPreviewForm.crypto_base_price_amount, + onChange: (event) => setOfferPreviewForm({ ...offerPreviewForm, crypto_base_price_amount: event.target.value }), + onBlur: () => rememberOfferBasisInput('crypto', offerPreviewForm.crypto_base_price_amount, offerPreviewForm.crypto_base_price_currency || 'USD'), + })) + : fieldWrapper('Basispreis fuer 100 kH/s auf 3 Monate in EUR', h('input', { + className: 'mc-input', + type: 'number', + step: '0.000001', + value: offerPreviewForm.fiat_base_price_amount, + onChange: (event) => setOfferPreviewForm({ ...offerPreviewForm, fiat_base_price_amount: event.target.value }), + onBlur: () => rememberOfferBasisInput('fiat', offerPreviewForm.fiat_base_price_amount, 'EUR'), + })), selectedOfferType === 'crypto' - ? selectField('Basiswaehrung', offerPreviewForm.crypto_base_price_currency || 'USD', Array.from(new Set(['USD', String(currentSettings.crypto_currency || 'DOGE').toUpperCase()])), (value) => setOfferPreviewForm({ ...offerPreviewForm, crypto_base_price_currency: value || 'USD' })) + ? fieldWrapper('Basiswaehrung', h('select', { + className: 'mc-select', + value: offerPreviewForm.crypto_base_price_currency || 'USD', + onChange: async (event) => { + const nextCurrency = event.target.value || 'USD'; + setOfferPreviewForm({ ...offerPreviewForm, crypto_base_price_currency: nextCurrency }); + await rememberOfferBasisInput('crypto', offerPreviewForm.crypto_base_price_amount, nextCurrency); + }, + }, Array.from(new Set(['USD', String(currentSettings.crypto_currency || 'DOGE').toUpperCase()])).map((value) => h('option', { key: value, value }, value)))) : displayField('Basiswaehrung', 'EUR'), selectField('Angebotsart', minerOfferFilters.offer_type, [ { value: 'crypto', label: 'Crypto' }, @@ -3095,6 +3165,14 @@ label: `${value} Monate`, }))), (value) => setMinerOfferFilters({ ...minerOfferFilters, runtime_months: value })), ]), + visibleOfferBasisHistory.length ? h('div', { key: 'basis-history', className: 'mc-mini-grid' }, visibleOfferBasisHistory.map((entry) => h('div', { + key: `basis-${entry.id}`, + className: 'mc-mini-card', + }, [ + h('div', { key: 'label', className: 'mc-field-label' }, selectedOfferType === 'crypto' ? 'Crypto-Basis' : 'Fiat-Basis'), + h('div', { key: 'value' }, `${fmtNumber(entry.base_price_amount, 6)} ${entry.base_price_currency}`), + h('div', { key: 'sub', className: 'mc-kicker' }, entry.created_at ? fmtDate(entry.created_at) : 'Zeitpunkt unbekannt'), + ]))) : null, h('div', { key: 'offers-table', className: 'mc-table-shell' }, [ h('table', { key: 'table', className: 'mc-table' }, [ h('thead', { key: 'head' }, h('tr', null, ['Label', 'Hashrate', 'Preis', 'Erwartet/Tag', 'Break-even', 'Empfehlung', 'Aktion'].map((label) => h('th', { key: label }, label)))), @@ -3102,18 +3180,41 @@ filteredMinerOffers.length ? filteredMinerOffers.map((offer) => h('tr', { key: offer.id }, [ h('td', { key: 'label' }, offer.label), - h('td', { key: 'hashrate' }, formatAdaptiveSpeed(offer.offer_hashrate_mh)), + h('td', { key: 'hashrate' }, [ + h('div', { key: 'base' }, formatSpeed(offer.mining_speed_value, offer.mining_speed_unit, 'Basis') || 'n/a'), + Number(offer.bonus_speed_value) > 0 + ? h('div', { key: 'bonus', className: 'mc-kicker' }, formatSpeed(offer.bonus_speed_value, offer.bonus_speed_unit, 'Bonus')) + : h('div', { key: 'bonus', className: 'mc-kicker' }, 'Bonus 0'), + h('div', { key: 'total', className: 'mc-kicker' }, `Gesamt ${formatAdaptiveSpeed(offer.offer_hashrate_mh)}`), + ]), h('td', { key: 'price' }, [ - h('div', { key: 'price-main' }, [ - h('div', { key: 'amount' }, `${fmtNumber(offer.effective_price_amount, 6)} ${offer.effective_price_currency}`), - h('div', { key: 'label', className: 'mc-kicker' }, 'Zu zahlen'), - ]), - offer.base_price_amount !== null && offer.base_price_currency - ? h('div', { key: 'price-base' }, [ - h('div', { key: 'amount' }, `${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`), - h('div', { key: 'label', className: 'mc-kicker' }, 'Gegenwert'), - ]) - : null, + offer.payment_type === 'crypto' + ? [ + offer.crypto_display_price_amount !== null && offer.crypto_display_price_currency + ? h('div', { key: 'price-crypto' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.crypto_display_price_amount, 6)} ${offer.crypto_display_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Zu zahlen in Krypto'), + ]) + : null, + offer.usd_display_price_amount !== null && offer.usd_display_price_currency + ? h('div', { key: 'price-usd' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.usd_display_price_amount, 6)} ${offer.usd_display_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Zu zahlen in USD'), + ]) + : null, + ] + : [ + h('div', { key: 'price-main' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.effective_price_amount, 6)} ${offer.effective_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Zu zahlen'), + ]), + offer.base_price_amount !== null && offer.base_price_currency + ? h('div', { key: 'price-base' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Gegenwert'), + ]) + : null, + ], ]), h('td', { key: 'day' }, offer.expected_doge_per_day !== null ? `${fmtNumber(offer.expected_doge_per_day, 6)} ${currentCoinCurrency}` : 'n/a'), h('td', { key: 'break' }, offer.break_even_days !== null ? `${fmtNumber(offer.break_even_days, 2)} Tage` : 'n/a'), diff --git a/custom/apps/mining-checker/sql/schema.mysql.sql b/custom/apps/mining-checker/sql/schema.mysql.sql index 331020dd..da79f30b 100644 --- a/custom/apps/mining-checker/sql/schema.mysql.sql +++ b/custom/apps/mining-checker/sql/schema.mysql.sql @@ -160,6 +160,18 @@ CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS miningcheck_offer_basis_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(191) NOT NULL DEFAULT 'local', + offer_type VARCHAR(16) NOT NULL, + base_price_amount DECIMAL(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_offer_basis_history_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_mining_offer_basis_history_project_owner_type_created (project_key, owner_sub, offer_type, created_at) +); + CREATE TABLE IF NOT EXISTS miningcheck_targets ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/custom/apps/mining-checker/sql/schema.pgsql.sql b/custom/apps/mining-checker/sql/schema.pgsql.sql index 19cac557..dccd1d61 100644 --- a/custom/apps/mining-checker/sql/schema.pgsql.sql +++ b/custom/apps/mining-checker/sql/schema.pgsql.sql @@ -173,6 +173,19 @@ CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS miningcheck_offer_basis_history ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(191) NOT NULL DEFAULT 'local', + offer_type VARCHAR(16) NOT NULL, + base_price_amount NUMERIC(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_offer_basis_history_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_mining_offer_basis_history_project_owner_type_created + ON miningcheck_offer_basis_history(project_key, owner_sub, offer_type, created_at); + CREATE TABLE IF NOT EXISTS miningcheck_targets ( id BIGSERIAL PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/custom/apps/mining-checker/sql/schema.sql b/custom/apps/mining-checker/sql/schema.sql index 331020dd..da79f30b 100644 --- a/custom/apps/mining-checker/sql/schema.sql +++ b/custom/apps/mining-checker/sql/schema.sql @@ -160,6 +160,18 @@ CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS miningcheck_offer_basis_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(191) NOT NULL DEFAULT 'local', + offer_type VARCHAR(16) NOT NULL, + base_price_amount DECIMAL(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_offer_basis_history_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_mining_offer_basis_history_project_owner_type_created (project_key, owner_sub, offer_type, created_at) +); + CREATE TABLE IF NOT EXISTS miningcheck_targets ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/custom/apps/mining-checker/src/Api/Router.php b/custom/apps/mining-checker/src/Api/Router.php index 29ab0cb9..efb6f136 100644 --- a/custom/apps/mining-checker/src/Api/Router.php +++ b/custom/apps/mining-checker/src/Api/Router.php @@ -153,6 +153,15 @@ final class Router $this->respond(['data' => $this->fxHistory()]); } + if ($resource === 'offer-basis-history' && $method === 'GET') { + $this->respond(['data' => $this->offerBasisHistory($projectKey)]); + } + + if ($resource === 'offer-basis-history' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + $this->respond(['data' => $this->saveOfferBasisHistory($projectKey, Http::input())], 201); + } + if ($resource === 'legacy-fx-migrate' && $method === 'POST') { $this->respond(['data' => $this->migrateLegacyFxRates($projectKey)], 201); } @@ -818,6 +827,11 @@ final class Router return $rows; } + private function offerBasisHistory(string $projectKey): array + { + return $this->repository()->listOfferBasisHistory($projectKey, 50); + } + private function migrateLegacyFxRates(string $projectKey): array { $fxRepository = $this->sharedFxRatesRepository(); @@ -1694,6 +1708,21 @@ final class Router } $measurements = $this->measurements($projectKey, $settings); + $freshFetchId = null; + if ($settings['crypto_base_price_amount'] !== null) { + try { + $fresh = $this->fx()->refreshLatestRates(null, 'USD'); + $freshFetchId = is_numeric($fresh['fetch_id'] ?? null) ? (int) $fresh['fetch_id'] : null; + } catch (\Throwable) { + $freshFetchId = null; + } + } + if ($freshFetchId !== null && $freshFetchId > 0 && $measurements !== []) { + $latestIndex = array_key_last($measurements); + if ($latestIndex !== null && is_array($measurements[$latestIndex])) { + $measurements[$latestIndex]['fx_fetch_id'] = $freshFetchId; + } + } $summary = $this->analytics()->buildSummary($measurements, $settings, [], [ 'include_offer_scenarios' => true, 'include_long_term_projection' => true, @@ -1701,9 +1730,38 @@ final class Router return [ 'miner_offers' => is_array($summary['miner_offers'] ?? null) ? $summary['miner_offers'] : [], + 'fx_fetch_id' => $freshFetchId, ]; } + private function saveOfferBasisHistory(string $projectKey, array $input): array + { + $offerType = $this->enumValue($input['offer_type'] ?? null, ['crypto', 'fiat'], 'offer_type'); + $basePriceAmount = $this->requiredDecimal($input['base_price_amount'] ?? null, 'base_price_amount'); + if ($basePriceAmount <= 0) { + throw new ApiException('base_price_amount muss groesser als 0 sein.', 422, ['field' => 'base_price_amount']); + } + + $basePriceCurrency = $offerType === 'crypto' + ? $this->requiredCurrency($input['base_price_currency'] ?? 'USD', 'base_price_currency') + : 'EUR'; + + if ($offerType === 'crypto') { + $cryptoCurrency = $this->requiredCurrency($this->settings($projectKey)['crypto_currency'] ?? 'DOGE', 'crypto_currency'); + if (!in_array($basePriceCurrency, ['USD', $cryptoCurrency], true)) { + throw new ApiException('Bei Crypto-Basispreisen sind nur USD oder die Projekt-Kryptowaehrung erlaubt.', 422, [ + 'field' => 'base_price_currency', + ]); + } + } + + return $this->repository()->saveOfferBasisEntry($projectKey, [ + 'offer_type' => $offerType, + 'base_price_amount' => $basePriceAmount, + 'base_price_currency' => $basePriceCurrency, + ]); + } + private function purchasedMiners(string $projectKey): array { return $this->repository()->listPurchasedMiners($projectKey); diff --git a/custom/apps/mining-checker/src/Domain/AnalyticsService.php b/custom/apps/mining-checker/src/Domain/AnalyticsService.php index 71233300..47fc23e7 100644 --- a/custom/apps/mining-checker/src/Domain/AnalyticsService.php +++ b/custom/apps/mining-checker/src/Domain/AnalyticsService.php @@ -1188,6 +1188,23 @@ final class AnalyticsService } $referencePriceAmount = $basePriceAmount; $referencePriceCurrency = $basePriceCurrency !== '' ? $basePriceCurrency : null; + $cryptoDisplayAmount = null; + $cryptoDisplayCurrency = null; + $usdDisplayAmount = null; + $usdDisplayCurrency = null; + + if ($paymentType === 'crypto') { + $cryptoDisplayCurrency = strtoupper(trim((string) ($settings['crypto_currency'] ?? 'DOGE'))); + $cryptoDisplayAmount = $this->convertAmount($basePriceAmount, $basePriceCurrency, $cryptoDisplayCurrency, $latest); + if ($cryptoDisplayAmount === null && strtoupper($basePriceCurrency) === $cryptoDisplayCurrency) { + $cryptoDisplayAmount = $basePriceAmount; + } + $usdDisplayCurrency = 'USD'; + $usdDisplayAmount = $this->convertAmount($basePriceAmount, $basePriceCurrency, 'USD', $latest); + if ($usdDisplayAmount === null && strtoupper($basePriceCurrency) === 'USD') { + $usdDisplayAmount = $basePriceAmount; + } + } $expectedDogePerDay = null; if ($currentHashrateMh > 0 && $offerHashrateMh > 0 && is_numeric($latest['doge_per_day_interval'] ?? null)) { @@ -1202,16 +1219,11 @@ final class AnalyticsService ? $effectivePriceAmount / $expectedDailyRevenue : null; - $recommendation = 'keine Basis'; - if ($breakEvenDays !== null) { - if ($breakEvenDays <= 180) { - $recommendation = 'lohnt eher'; - } elseif ($breakEvenDays <= 365) { - $recommendation = 'abwaegen'; - } else { - $recommendation = 'eher warten'; - } - } + $recommendation = $this->offerRecommendation( + $breakEvenDays, + (int) ($offer['runtime_months'] ?? 0), + !empty($offer['auto_renew']) + ); return array_merge($offer, [ 'payment_type' => $paymentType, @@ -1222,6 +1234,10 @@ final class AnalyticsService 'effective_price_currency' => $effectivePriceCurrency, 'reference_price_amount' => $this->roundOrNull($referencePriceAmount, 8), 'reference_price_currency' => $referencePriceCurrency !== '' ? $referencePriceCurrency : null, + 'crypto_display_price_amount' => $this->roundOrNull($cryptoDisplayAmount, 8), + 'crypto_display_price_currency' => $cryptoDisplayCurrency, + 'usd_display_price_amount' => $this->roundOrNull($usdDisplayAmount, 8), + 'usd_display_price_currency' => $usdDisplayCurrency, 'expected_doge_per_day' => $this->roundOrNull($expectedDogePerDay, 6), 'expected_daily_revenue' => $this->roundOrNull($expectedDailyRevenue, 8), 'break_even_days' => $this->roundOrNull($breakEvenDays, 2), @@ -1229,6 +1245,34 @@ final class AnalyticsService ]); } + private function offerRecommendation(?float $breakEvenDays, int $runtimeMonths, bool $autoRenew): string + { + if ($breakEvenDays === null) { + return 'keine Basis'; + } + + if (!$autoRenew && $runtimeMonths > 0) { + $runtimeDays = $runtimeMonths * 30.0; + if ($breakEvenDays <= ($runtimeDays * 0.85)) { + return 'lohnt eher'; + } + if ($breakEvenDays <= $runtimeDays) { + return 'abwaegen'; + } + + return 'eher warten'; + } + + if ($breakEvenDays <= 180) { + return 'lohnt eher'; + } + if ($breakEvenDays <= 365) { + return 'abwaegen'; + } + + return 'eher warten'; + } + private function enrichOfferScenario(array $offer, array $latest, float $currentHashrateMh, array $costPlans, array $purchasedMiners): array { $latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''); diff --git a/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php b/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php index 383a84d8..6c7a3c3d 100644 --- a/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php +++ b/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php @@ -111,6 +111,92 @@ final class MiningRepository ]); } + public function listOfferBasisHistory(string $projectKey, int $limit = 50): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('offer_basis_history') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY created_at DESC, id DESC + LIMIT :limit' + ); + $stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR); + $stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function latestOfferBasisEntry(string $projectKey, string $offerType): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('offer_basis_history') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub AND offer_type = :offer_type + ORDER BY created_at DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'offer_type' => strtolower(trim($offerType)), + ]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function saveOfferBasisEntry(string $projectKey, array $payload): array + { + $normalizedType = strtolower(trim((string) ($payload['offer_type'] ?? ''))); + $latest = $this->latestOfferBasisEntry($projectKey, $normalizedType); + if ( + is_array($latest) + && (string) ($latest['base_price_currency'] ?? '') === (string) ($payload['base_price_currency'] ?? '') + && is_numeric($latest['base_price_amount'] ?? null) + && is_numeric($payload['base_price_amount'] ?? null) + && abs((float) $latest['base_price_amount'] - (float) $payload['base_price_amount']) < 0.00000001 + ) { + return $latest; + } + + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('offer_basis_history') . ' ( + project_key, owner_sub, offer_type, base_price_amount, base_price_currency + ) VALUES ( + :project_key, :owner_sub, :offer_type, :base_price_amount, :base_price_currency + ) + RETURNING *' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'offer_type' => $normalizedType, + 'base_price_amount' => $payload['base_price_amount'], + 'base_price_currency' => $payload['base_price_currency'], + ]); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('offer_basis_history') . ' ( + project_key, owner_sub, offer_type, base_price_amount, base_price_currency + ) VALUES ( + :project_key, :owner_sub, :offer_type, :base_price_amount, :base_price_currency + )' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'offer_type' => $normalizedType, + 'base_price_amount' => $payload['base_price_amount'], + 'base_price_currency' => $payload['base_price_currency'], + ]); + + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('offer_basis_history') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + public function tableExists(string $logicalName): bool { $tableName = $this->table($logicalName); @@ -1384,6 +1470,7 @@ final class MiningRepository 'wallet_snapshots' => $this->prefix . 'wallet_snapshots', 'wallet_withdrawals' => $this->prefix . 'wallet_withdrawals', 'miner_offers' => $this->prefix . 'miner_offers', + 'offer_basis_history' => $this->prefix . 'offer_basis_history', 'purchased_miners' => $this->prefix . 'purchased_miners', default => throw new \RuntimeException('Unknown mining table: ' . $logicalName), }; diff --git a/custom/apps/mining-checker/src/Infrastructure/SchemaManager.php b/custom/apps/mining-checker/src/Infrastructure/SchemaManager.php index 3ba782e5..af7be65f 100644 --- a/custom/apps/mining-checker/src/Infrastructure/SchemaManager.php +++ b/custom/apps/mining-checker/src/Infrastructure/SchemaManager.php @@ -217,6 +217,10 @@ final class SchemaManager $this->upgradeMinerOffersTable(); $applied[] = 'miner_offers_table'; } + if (!$this->tableExists($this->prefix . 'offer_basis_history')) { + $this->ensureOfferBasisHistoryTable(); + $applied[] = 'offer_basis_history_table'; + } if (!$this->tableExists($this->prefix . 'purchased_miners')) { $this->upgradePurchasedMinersTable(); $applied[] = 'purchased_miners_table'; @@ -337,6 +341,10 @@ final class SchemaManager $this->upgradeMinerOffersTable(); $applied[] = 'miner_offers_table'; } + if (!$this->tableExists($this->prefix . 'offer_basis_history')) { + $this->ensureOfferBasisHistoryTable(); + $applied[] = 'offer_basis_history_table'; + } if (!$this->tableExists($this->prefix . 'purchased_miners')) { $this->upgradePurchasedMinersTable(); @@ -693,6 +701,9 @@ final class SchemaManager if (!$this->tableExists($this->prefix . 'miner_offers')) { $upgrades[] = 'miner_offers_table'; } + if (!$this->tableExists($this->prefix . 'offer_basis_history')) { + $upgrades[] = 'offer_basis_history_table'; + } if (!$this->tableExists($this->prefix . 'purchased_miners')) { $upgrades[] = 'purchased_miners_table'; } @@ -1179,6 +1190,45 @@ final class SchemaManager $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Miner-Angebote fehlgeschlagen.'); } + private function ensureOfferBasisHistoryTable(): void + { + if ($this->tableExists($this->prefix . 'offer_basis_history')) { + return; + } + + $table = $this->prefix . 'offer_basis_history'; + $projectTable = $this->prefix . 'projects'; + $statements = $this->driver === 'pgsql' + ? [ + 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(191) NOT NULL DEFAULT \'local\', + offer_type VARCHAR(16) NOT NULL, + base_price_amount NUMERIC(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_offer_basis_history_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE + )', + 'CREATE INDEX IF NOT EXISTS idx_mining_offer_basis_history_project_owner_type_created ON ' . $table . ' (project_key, owner_sub, offer_type, created_at)', + ] + : [ + 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(191) NOT NULL DEFAULT \'local\', + offer_type VARCHAR(16) NOT NULL, + base_price_amount DECIMAL(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_offer_basis_history_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE, + KEY idx_mining_offer_basis_history_project_owner_type_created (project_key, owner_sub, offer_type, created_at) + )', + ]; + + $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Angebots-Basis-Historie fehlgeschlagen.'); + } + private function upgradePurchasedMinersTable(): void { $table = $this->prefix . 'purchased_miners'; @@ -1528,6 +1578,7 @@ final class SchemaManager $this->prefix . 'wallet_snapshots', $this->prefix . 'wallet_withdrawals', $this->prefix . 'miner_offers', + $this->prefix . 'offer_basis_history', $this->prefix . 'targets', $this->prefix . 'dashboard_definitions', $this->prefix . 'purchased_miners',