From fc95898a9da3939213ec55c470f738599b388a1a Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Sat, 9 May 2026 00:58:48 +0200 Subject: [PATCH] Miner-Upgrade --- modules/mining-checker/assets/js/app.js | 305 ++++++++++++------ modules/mining-checker/design.json | 1 + .../sql/migrations/001_init.sql | 23 ++ modules/mining-checker/sql/schema.mysql.sql | 23 ++ modules/mining-checker/sql/schema.pgsql.sql | 25 ++ modules/mining-checker/sql/schema.sql | 23 ++ modules/mining-checker/src/Api/Router.php | 111 ++++++- .../src/Domain/AnalyticsService.php | 259 +++++++++++++-- .../mining-checker/src/Domain/OcrService.php | 129 +++++++- .../src/Infrastructure/MiningRepository.php | 77 ++++- .../src/Infrastructure/SchemaManager.php | 131 ++++++++ 11 files changed, 976 insertions(+), 131 deletions(-) diff --git a/modules/mining-checker/assets/js/app.js b/modules/mining-checker/assets/js/app.js index aef8e73..bc1d7aa 100644 --- a/modules/mining-checker/assets/js/app.js +++ b/modules/mining-checker/assets/js/app.js @@ -344,6 +344,7 @@ purchased_miners: [], measurement_rates: [], }, + wallet_snapshots: Array.isArray(normalized.wallet_snapshots) ? normalized.wallet_snapshots : [], measurements: Array.isArray(normalized.measurements) ? normalized.measurements : [], targets: Array.isArray(normalized.targets) ? normalized.targets : [], dashboards: Array.isArray(normalized.dashboards) ? normalized.dashboards : [], @@ -352,7 +353,15 @@ latest_measurement: null, baseline: normalized.settings || null, targets: Array.isArray(normalized.targets) ? normalized.targets : [], - payouts: { total_count: 0, total_coins: 0, current_visible_coins: null, current_effective_coins: null }, + payouts: { + total_count: 0, + total_coins: 0, + current_visible_coins: null, + current_effective_coins: null, + wallet_balances: {}, + wallet_balance_current_asset: null, + holdings_current_asset: null, + }, current_hashrate_mh: null, miner_offers: [], }, @@ -380,6 +389,7 @@ : {}; return { + kind: normalized.kind === 'wallet' ? 'wallet' : 'measurement', suggested: { measured_at: suggested.measured_at || '', coins_total: suggested.coins_total ?? '', @@ -388,6 +398,29 @@ note: suggested.note || '', source: suggested.source || 'image_ocr', }, + suggested_wallet: normalized.suggested_wallet && typeof normalized.suggested_wallet === 'object' + ? { + measured_at: normalized.suggested_wallet.measured_at || '', + total_value_amount: normalized.suggested_wallet.total_value_amount ?? '', + total_value_currency: normalized.suggested_wallet.total_value_currency || '', + wallet_balance: normalized.suggested_wallet.wallet_balance ?? '', + wallet_currency: normalized.suggested_wallet.wallet_currency || '', + balances_json: normalized.suggested_wallet.balances_json && typeof normalized.suggested_wallet.balances_json === 'object' + ? normalized.suggested_wallet.balances_json + : {}, + note: normalized.suggested_wallet.note || '', + source: normalized.suggested_wallet.source || 'image_ocr', + } + : { + measured_at: '', + total_value_amount: '', + total_value_currency: '', + wallet_balance: '', + wallet_currency: '', + balances_json: {}, + note: '', + source: 'image_ocr', + }, confidence: typeof normalized.confidence === 'number' ? normalized.confidence : 0, flags: Array.isArray(normalized.flags) ? normalized.flags : [], image_path: normalized.image_path || '', @@ -790,6 +823,7 @@ ]; const currentCostPlans = Array.isArray(currentSettings.cost_plans) ? currentSettings.cost_plans : []; const currentPayouts = Array.isArray(currentSettings.payouts) ? currentSettings.payouts : []; + const currentWalletSnapshots = Array.isArray(payload?.wallet_snapshots) ? payload.wallet_snapshots : []; const currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : []; const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : []; const renewableOfferIds = new Set( @@ -925,13 +959,13 @@ setPurchaseMinerForm((current) => ({ ...current, offer_id: current.offer_id || String(selectedOffer.id), - currency: current.currency || selectedOffer.effective_price_currency || selectedOffer.base_price_currency || 'USD', + currency: current.currency || (!selectedOffer.auto_renew ? (currentSettings.crypto_currency || 'DOGE') : (selectedOffer.effective_price_currency || selectedOffer.base_price_currency || 'USD')), total_cost_amount: current.total_cost_amount || (selectedOffer.effective_price_amount !== null && selectedOffer.effective_price_amount !== undefined ? String(selectedOffer.effective_price_amount) : ''), reference_price_amount: current.reference_price_amount || (selectedOffer.reference_price_amount !== null && selectedOffer.reference_price_amount !== undefined ? String(selectedOffer.reference_price_amount) : ''), reference_price_currency: current.reference_price_currency || selectedOffer.reference_price_currency || '', auto_renew: current.auto_renew || !!selectedOffer.auto_renew, })); - }, [purchaseMinerModalOpen, availableMinerOffers, purchaseMinerForm.offer_id]); + }, [purchaseMinerModalOpen, availableMinerOffers, purchaseMinerForm.offer_id, currentSettings.crypto_currency]); function measurementFxRate(measurementId, fromCurrency, toCurrency) { const from = String(fromCurrency || '').toUpperCase(); @@ -1167,6 +1201,7 @@ }, [payload, projectKey]); const overviewCharts = useMemo(() => { + const chartCoinCurrency = String(currentSettings.crypto_currency || 'DOGE').toUpperCase(); const comparisonRows = measurements.filter((row) => { const miningRate = Number(row.doge_per_hour_per_mh_interval); const price = Number(row.effective_price_per_coin ?? row.price_per_coin); @@ -1185,7 +1220,7 @@ miningVsPrice: baseMining && basePrice ? [ { key: 'mining-rate', - label: 'Mining/h je MH/s Index', + label: `${chartCoinCurrency}/h je MH/s Index`, color: '#2dd4bf', data: comparisonRows.map((row) => ({ x: fmtDate(row.measured_at), @@ -1194,7 +1229,7 @@ }, { key: 'doge-price', - label: 'DOGE-Kurs Index', + label: `${chartCoinCurrency}-Kurs Index`, color: '#f59e0b', data: comparisonRows.map((row) => ({ x: fmtDate(row.measured_at), @@ -1203,7 +1238,7 @@ }, ] : [], }; - }, [measurements]); + }, [currentSettings.crypto_currency, measurements]); async function submitMeasurement(fromPreview) { const preview = normalizeOcrPreview(ocrPreview); @@ -1242,6 +1277,35 @@ } } + async function submitWalletSnapshotFromPreview() { + const preview = normalizeOcrPreview(ocrPreview); + const raw = { + ...preview.suggested_wallet, + image_path: preview.image_path, + ocr_raw_text: preview.raw_text, + ocr_confidence: preview.confidence, + ocr_flags: preview.flags, + }; + + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/wallet-snapshots`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(raw), + }); + setMessage('Wallet-Snapshot gespeichert.'); + setOcrPreview(null); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + async function deleteMeasurement(id) { if (!id) { return; @@ -1320,6 +1384,7 @@ body.append('image', nextForm.image); body.append('date_context', nextForm.date_context); body.append('ocr_hint_text', nextForm.ocr_hint_text); + body.append('wallet_currency_hint', currentSettings.crypto_currency || 'DOGE'); const data = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/ocr-preview`, { method: 'POST', body, @@ -1534,7 +1599,7 @@ body: JSON.stringify(payoutForm), }); setMessage('Auszahlung gespeichert.'); - setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: 'DOGE', note: '' }); + setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentSettings.crypto_currency || 'DOGE', note: '' }); setPayoutModalOpen(false); await loadBootstrap(projectKey); } catch (err) { @@ -1792,6 +1857,75 @@ setReportCurrencyOverride(''); setCookie('mining_checker_report_currency', '', 0); } + + function renderSharedOcrPanel() { + const preview = normalizeOcrPreview(ocrPreview); + const isWalletPreview = preview.kind === 'wallet'; + const hasUsableOcrSuggestion = isWalletPreview + ? (preview.suggested_wallet.wallet_balance !== '' && preview.suggested_wallet.wallet_balance !== null) + : (preview.suggested.coins_total !== '' && preview.suggested.coins_total !== null); + const ocrStatus = getOcrStatusMessage(preview); + + return panel('OCR Upload', 'Screenshot auswaehlen, Ergebnis direkt pruefen und speichern.', [ + h('div', { + key: 'ocr-form', + className: 'mc-form', + }, [ + fileField('Screenshot', (file) => loadOcrPreview(file, { image: file })), + ]), + saving && ocrForm.image + ? h('div', { key: 'ocr-loading', className: 'mc-empty' }, 'Analysiere Screenshot …') + : null, + ocrPreview + ? h('div', { key: 'ocr-preview', className: 'mc-form' }, [ + h('div', { key: 'badges', className: 'mc-inline-row' }, [ + h(Badge, { key: 'kind', tone: isWalletPreview ? 'info' : 'success' }, isWalletPreview ? 'Wallet' : 'Mining'), + h(Badge, { key: 'confidence', tone: preview.confidence >= 0.75 ? 'success' : 'warn' }, `confidence ${fmtNumber(preview.confidence, 4)}`), + ]), + isWalletPreview + ? h('div', { key: 'wallet-form', className: 'mc-two-col' }, [ + displayField('Erkannter Typ', 'Wallet-Snapshot'), + displayField('Mining-Waehrung im Wallet', `${fmtNumber(preview.suggested_wallet.wallet_balance, 8)} ${preview.suggested_wallet.wallet_currency || currentSettings.crypto_currency || 'DOGE'}`), + displayField('Gesamtwert', preview.suggested_wallet.total_value_amount !== '' && preview.suggested_wallet.total_value_amount !== null + ? `${fmtNumber(preview.suggested_wallet.total_value_amount, 4)} ${preview.suggested_wallet.total_value_currency || ''}`.trim() + : 'n/a'), + displayField('Erkannte Wallet-Assets', Object.entries(preview.suggested_wallet.balances_json || {}) + .slice(0, 8) + .map(([code, amount]) => `${fmtNumber(amount, 8)} ${code}`) + .join(' · ') || 'n/a'), + ]) + : h('div', { key: 'measurement-form', className: 'mc-two-col' }, [ + displayField('Erkannter Typ', 'Mining-Messpunkt'), + displayField('Datum/Zeit', 'Wird beim Speichern auf den aktuellen Bestätigungszeitpunkt gesetzt.'), + displayField('Coins total', fmtNumber(preview.suggested.coins_total, 6)), + displayField('Kurs', fmtNumber(preview.suggested.price_per_coin, 6)), + displayField('Waehrung', preview.suggested.price_currency || 'n/a'), + ]), + ocrStatus + ? h('div', { + key: 'ocr-status', + className: cx( + 'mc-alert', + ocrStatus.tone === 'error' ? 'mc-alert--error' : 'mc-alert--warning' + ), + }, ocrStatus.text) + : null, + !hasUsableOcrSuggestion + ? h('div', { key: 'ocr-warning', className: 'mc-alert mc-alert--error' }, + 'Kein verwertbarer OCR-Vorschlag erkannt. Bitte Bild erneut hochladen oder die Daten manuell erfassen.') + : null, + h('button', { + key: 'confirm', + type: 'button', + className: 'mc-button mc-button--primary', + onClick: () => isWalletPreview ? submitWalletSnapshotFromPreview() : submitMeasurement(true), + disabled: saving || !hasUsableOcrSuggestion, + }, saving ? 'Speichert …' : 'Ergebnis speichern'), + ]) + : h('div', { key: 'ocr-empty', className: 'mc-empty' }, + 'Noch kein Screenshot ausgewaehlt.'), + ]); + } return h('div', { className: 'mc-grid-bg', }, [ @@ -1804,6 +1938,9 @@ ]); function renderTab() { + const currentCoinCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase(); + const perDayLabel = `${currentCoinCurrency} pro Tag`; + if (activeTab === 'overview') { const latestValue = latest ? convertMeasurementMoney(latest, latest.current_value, reportCurrency) : null; const latestPriceSource = latest && latest.effective_price_per_coin !== null && latest.effective_price_per_coin !== undefined @@ -1822,18 +1959,14 @@ const breakEvenDaysOverall = latest && latest.break_even_days_overall !== null && latest.break_even_days_overall !== undefined ? Number(latest.break_even_days_overall) : null; - const investedCapital = latest ? convertMeasurementMoney(latest, latest.invested_capital, reportCurrency) : null; + const investedCapital = latest ? convertMeasurementMoney(latest, latest.cash_invested_capital ?? latest.invested_capital, reportCurrency) : null; + const reinvestedCapital = latest ? convertMeasurementMoney(latest, latest.reinvested_capital, reportCurrency) : null; + const walletValue = latest ? convertMeasurementMoney(latest, latest.wallet_value, reportCurrency) : null; + const totalHoldingsValue = latest ? convertMeasurementMoney(latest, latest.total_holdings_value, reportCurrency) : null; const breakEvenReached = breakEvenRemainingAmount !== null && breakEvenRemainingAmount <= 0; - const breakEvenDate = (() => { - if (!latest || breakEvenDaysOverall === null || !Number.isFinite(breakEvenDaysOverall)) { - return null; - } - const baseTimestamp = Date.parse(String(latest.measured_at || '')); - if (!Number.isFinite(baseTimestamp)) { - return null; - } - return new Date(baseTimestamp + (breakEvenDaysOverall * 24 * 60 * 60 * 1000)); - })(); + const breakEvenEta = latest && latest.break_even_eta_at ? fmtDate(latest.break_even_eta_at) : null; + const walletBalanceCurrentAsset = payload?.summary?.payouts?.wallet_balance_current_asset; + const holdingsCurrentAsset = payload?.summary?.payouts?.holdings_current_asset; return h('div', { className: 'mc-stack' }, [ panel('Berichtswährung', 'Bestimmt die Währung für Kennzahlen im Überblick. Standard kommt aus den Settings, diese Auswahl gilt nur für den aktuellen Besuch.', [ @@ -1855,19 +1988,21 @@ h('div', { key: 'stats', className: 'mc-stats-grid' }, [ h(StatCard, { key: 'coins', - label: 'Coins sichtbar', + label: `${currentCoinCurrency} im Miner`, value: latest ? fmtNumber(latest.coins_total_visible || latest.coins_total, 6) : 'n/a', sub: latest ? `Stand ${fmtDate(latest.measured_at)}` : '', }), h(StatCard, { - key: 'coins-effective', - label: 'Coins effektiv', - value: payload?.summary?.payouts ? fmtNumber(payload.summary.payouts.current_effective_coins, 6) : 'n/a', - sub: payload?.summary?.payouts ? `Ausgezahlt ${fmtNumber(payload.summary.payouts.total_coins, 6)} DOGE` : '', + key: 'holdings', + label: `Theoretischer Bestand ${currentCoinCurrency}`, + value: payload?.summary?.payouts ? fmtNumber(holdingsCurrentAsset, 6) : 'n/a', + sub: payload?.summary?.payouts + ? `Wallet ${fmtNumber(walletBalanceCurrentAsset, 6)} ${currentCoinCurrency} · Miner ${fmtNumber(latest?.coins_total_visible || latest?.coins_total, 6)} ${currentCoinCurrency}` + : '', }), h(StatCard, { key: 'perday', - label: 'DOGE pro Tag', + label: perDayLabel, value: latest ? fmtNumber(latest.doge_per_day_interval, 4) : 'n/a', sub: payload?.summary?.current_hashrate_mh ? `Hashrate ${fmtNumber(payload.summary.current_hashrate_mh, 4)} MH/s` : (latest ? `Trend ${latest.trend_label}` : ''), }), @@ -1884,17 +2019,19 @@ label: 'Theoretischer Tagesgewinn', value: dailyProfit !== null ? fmtMoney(dailyProfit, reportCurrency) : 'n/a', sub: dailyCost !== null - ? `Tageskosten ${fmtMoney(dailyCost, reportCurrency)} · Break-even ${breakEvenPrice !== null ? `${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}` : 'n/a'}` + ? `Tageskosten ${fmtMoney(dailyCost, reportCurrency)} · Walletwert ${walletValue !== null ? fmtMoney(walletValue, reportCurrency) : 'n/a'}` : 'Kein aktiver Miner fuer diese Waehrung', }), h(StatCard, { key: 'break-even-point', - label: 'Break-even', - value: breakEvenDaysOverall !== null + label: 'Cash-Break-even', + value: breakEvenReached + ? 'Erreicht' + : breakEvenDaysOverall !== null ? `${fmtNumber(breakEvenDaysOverall, 2)} Tage` - : (breakEvenReached ? 'Erreicht' : (investedCapital === null ? 'Keine Mietbasis' : 'n/a')), + : (investedCapital === null ? 'Keine Mietbasis' : 'Nicht erreichbar'), sub: investedCapital !== null - ? `${breakEvenDate ? `Theoretisch ${fmtDate(breakEvenDate.toISOString())} · ` : ''}Basis ${fmtMoney(investedCapital, reportCurrency)}${dailyRevenue !== null ? ` · Tagesumsatz ${fmtMoney(dailyRevenue, reportCurrency)}` : ''}` + ? `${breakEvenEta ? `ETA ${breakEvenEta} · ` : ''}Cash ${fmtMoney(investedCapital, reportCurrency)}${reinvestedCapital !== null ? ` · Reinvest ${fmtMoney(reinvestedCapital, reportCurrency)}` : ''}${totalHoldingsValue !== null ? ` · Bestand ${fmtMoney(totalHoldingsValue, reportCurrency)}` : ''}` : (breakEvenPrice !== null ? `Break-even-Kurs ${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}` : (investedCapital === null @@ -1904,14 +2041,14 @@ ]), h('div', { key: 'charts', className: 'mc-overview-grid' }, [ panel('Mining-Verlauf', 'Coins total der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.mining })), - panel('Performance-Verlauf', 'DOGE-pro-Tag-Raten der letzten 15 Tage.', h(SimpleChart, { type: 'area', data: overviewCharts.performance })), + panel('Performance-Verlauf', `${perDayLabel}-Raten der letzten 15 Tage.`, h(SimpleChart, { type: 'area', data: overviewCharts.performance })), panel('Kurs-Verlauf', 'Preiswerte der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.pricing })), - panel('Mining vs. Kurs', 'Index-Vergleich der letzten 15 Tage. Mining wird auf DOGE pro Stunde je MH/s normalisiert, beide Reihen starten bei 100.', h(SimpleChart, { + panel('Mining vs. Kurs', `Index-Vergleich der letzten 15 Tage. Mining wird auf ${currentCoinCurrency} pro Stunde je MH/s normalisiert, beide Reihen starten bei 100.`, h(SimpleChart, { type: 'line', series: overviewCharts.miningVsPrice, })), ]), - panel('Zielmonitor', 'Rest-DOGE und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.', + panel('Zielmonitor', `Rest-${currentCoinCurrency} und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.`, h('div', { className: 'mc-target-grid' }, currentTargets.map((target, index) => h('div', { key: index, @@ -1924,8 +2061,8 @@ h('div', { key: 'body', className: 'mc-text mc-target-grid' }, [ h('div', { key: 'amount' }, `Ziel: ${fmtMoney(target.target_amount_fiat, target.currency)}`), h('div', { key: 'price' }, `Letzter Kurs: ${target.latest_price_for_currency ? fmtNumber(target.latest_price_for_currency, 6) + ' ' + target.currency : 'n/a'}`), - h('div', { key: 'doge' }, `Benoetigte DOGE: ${fmtNumber(target.required_doge, 6)}`), - h('div', { key: 'remaining' }, `Rest-DOGE: ${fmtNumber(target.remaining_doge, 6)}`), + h('div', { key: 'doge' }, `Benoetigte ${currentCoinCurrency}: ${fmtNumber(target.required_doge, 6)}`), + h('div', { key: 'remaining' }, `Rest-${currentCoinCurrency}: ${fmtNumber(target.remaining_doge, 6)}`), h('div', { key: 'days' }, `Resttage: ${fmtNumber(target.remaining_days, 4)}`), ]), ])) @@ -1937,56 +2074,7 @@ if (activeTab === 'measurements') { return h('div', { className: 'mc-main-grid' }, [ h('div', { className: 'mc-stack' }, [ - (function () { - const preview = normalizeOcrPreview(ocrPreview); - const hasUsableOcrSuggestion = preview.suggested.coins_total !== '' && preview.suggested.coins_total !== null; - const ocrStatus = getOcrStatusMessage(preview); - return panel('OCR Upload', 'Screenshot auswaehlen, Ergebnis direkt pruefen und speichern.', [ - h('div', { - key: 'ocr-form', - className: 'mc-form', - }, [ - fileField('Screenshot', (file) => loadOcrPreview(file, { image: file })), - ]), - saving && ocrForm.image - ? h('div', { key: 'ocr-loading', className: 'mc-empty' }, 'Analysiere Screenshot …') - : null, - ocrPreview - ? h('div', { key: 'ocr-preview', className: 'mc-form' }, [ - h('div', { key: 'badges', className: 'mc-inline-row' }, [ - h(Badge, { key: 'confidence', tone: preview.confidence >= 0.75 ? 'success' : 'warn' }, `confidence ${fmtNumber(preview.confidence, 4)}`), - ]), - h('div', { key: 'form', className: 'mc-two-col' }, [ - displayField('Datum/Zeit', 'Wird beim Speichern auf den aktuellen Bestätigungszeitpunkt gesetzt.'), - displayField('Coins total', fmtNumber(preview.suggested.coins_total, 6)), - displayField('Kurs', fmtNumber(preview.suggested.price_per_coin, 6)), - displayField('Waehrung', preview.suggested.price_currency || 'n/a'), - ]), - ocrStatus - ? h('div', { - key: 'ocr-status', - className: cx( - 'mc-alert', - ocrStatus.tone === 'error' ? 'mc-alert--error' : 'mc-alert--warning' - ), - }, ocrStatus.text) - : null, - !hasUsableOcrSuggestion - ? h('div', { key: 'ocr-warning', className: 'mc-alert mc-alert--error' }, - 'Kein verwertbarer OCR-Vorschlag erkannt. Bitte Bild erneut hochladen oder den Messpunkt manuell erfassen.') - : null, - h('button', { - key: 'confirm', - type: 'button', - className: 'mc-button mc-button--primary', - onClick: () => submitMeasurement(true), - disabled: saving || !hasUsableOcrSuggestion, - }, saving ? 'Speichert …' : 'Ergebnis speichern'), - ]) - : h('div', { key: 'ocr-empty', className: 'mc-empty' }, - 'Noch kein Screenshot ausgewaehlt.'), - ]); - })(), + renderSharedOcrPanel(), panel('Messpunkt manuell erfassen', 'Direkte Eingabe eines einzelnen Messpunkts mit serverseitiger Validierung.', h('form', { className: 'mc-form', onSubmit: function (event) { @@ -2068,12 +2156,12 @@ panel('Messhistorie', 'Die letzten 10 Uploads inkl. Performance-Werten und OCR-Metadaten.', h('div', { className: 'mc-table-shell' }, [ h('table', { key: 'table', className: 'mc-table' }, [ h('thead', { key: 'thead' }, h('tr', null, [ - 'Zeit', 'Coins', 'Kurs', 'Quelle', 'DOGE/Tag', 'Trend', 'Notiz', 'Aktion' + 'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Trend', 'Notiz', 'Aktion' ].map((label) => h('th', { key: label }, label)))), h('tbody', { key: 'tbody' }, measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [ h('td', { key: 'measured' }, fmtDate(row.measured_at)), - h('td', { key: 'coins' }, fmtNumber(row.coins_total, 6)), + h('td', { key: 'coins' }, `${fmtNumber(row.coins_total, 6)} ${row.coin_currency || currentCoinCurrency}`), h('td', { key: 'price' }, row.price_per_coin ? `${fmtNumber(row.price_per_coin, 6)} ${row.price_currency}` : 'n/a'), h('td', { key: 'source' }, row.source), h('td', { key: 'rate' }, fmtNumber(row.doge_per_day_interval, 4)), @@ -2092,6 +2180,33 @@ ]); } + if (activeTab === 'wallet') { + return h('div', { className: 'mc-main-grid' }, [ + h('div', { className: 'mc-stack' }, [ + renderSharedOcrPanel(), + panel('Wallet-Historie', `Erkannte Wallet-Snapshots. Fuer Reinvestitionen ist aktuell ${currentSettings.crypto_currency || 'DOGE'} entscheidend.`, h('div', { className: 'mc-table-shell' }, [ + h('table', { key: 'wallet-table', className: 'mc-table' }, [ + h('thead', { key: 'thead' }, h('tr', null, [ + 'Zeit', 'Mining-Waehrung', 'Bestand', 'Gesamtwert', 'Quelle', 'Assets' + ].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'tbody' }, + currentWalletSnapshots.length + ? currentWalletSnapshots.map((row) => h('tr', { key: row.id }, [ + h('td', { key: 'measured' }, fmtDate(row.measured_at)), + h('td', { key: 'currency' }, row.wallet_currency || currentSettings.crypto_currency || 'DOGE'), + h('td', { key: 'balance' }, row.wallet_balance !== null && row.wallet_balance !== undefined ? `${fmtNumber(row.wallet_balance, 8)} ${row.wallet_currency || ''}`.trim() : 'n/a'), + h('td', { key: 'value' }, row.total_value_amount !== null && row.total_value_amount !== undefined ? `${fmtNumber(row.total_value_amount, 4)} ${row.total_value_currency || ''}`.trim() : 'n/a'), + h('td', { key: 'source' }, row.source || 'manual'), + h('td', { key: 'assets' }, Object.entries(row.balances_json || {}).slice(0, 6).map(([code, amount]) => `${fmtNumber(amount, 8)} ${code}`).join(' · ') || 'n/a'), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 6 }, 'Noch keine Wallet-Snapshots gespeichert.'))] + ), + ]), + ])), + ]), + ]); + } + if (activeTab === 'dashboards') { return h('div', { className: 'mc-main-grid' }, [ panel('Dashboard-Builder V1', 'Chart-Typ, X/Y-Feld, Aggregation und einfache Filter werden gespeichert.', h('form', { @@ -2194,7 +2309,15 @@ key: 'add-payout', type: 'button', className: 'mc-button mc-button--secondary', - onClick: () => setPayoutModalOpen(true), + onClick: () => { + setPayoutForm((previous) => ({ + payout_at: previous.payout_at || nowDateTimeLocalValue(), + coins_amount: previous.coins_amount || '', + payout_currency: currentCoinCurrency, + note: previous.note || '', + })); + setPayoutModalOpen(true); + }, }, 'Auszahlung erfassen'), ]), h('div', { key: 'payout-list', className: 'mc-table-shell' }, [ @@ -2254,7 +2377,7 @@ ]) : null, ]), - h('td', { key: 'day' }, offer.expected_doge_per_day !== null ? `${fmtNumber(offer.expected_doge_per_day, 6)} DOGE` : 'n/a'), + 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'), h('td', { key: 'rec' }, [ h('div', { key: 'rec-main' }, offer.recommendation), @@ -2326,11 +2449,11 @@ }), h(StatCard, { key: 'scenario-doge', - label: 'DOGE pro Tag Neu', + label: `${currentCoinCurrency} pro Tag Neu`, value: selectedMinerScenario.scenario_doge_per_day !== null ? fmtNumber(selectedMinerScenario.scenario_doge_per_day, 4) : 'n/a', sub: selectedMinerScenario.scenario_current_doge_per_day !== null ? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_doge_per_day, 4)}` - : 'Keine aktuelle DOGE/Tag-Basis', + : `Keine aktuelle ${currentCoinCurrency}/Tag-Basis`, }), h(StatCard, { key: 'scenario-break-even', @@ -2445,7 +2568,7 @@ h('form', { key: 'form', className: 'mc-form', onSubmit: submitPayout }, [ inputField('Auszahlungszeitpunkt', 'datetime-local', payoutForm.payout_at, (value) => setPayoutForm({ ...payoutForm, payout_at: value })), inputField('Coins', 'number', payoutForm.coins_amount, (value) => setPayoutForm({ ...payoutForm, coins_amount: value }), '0.000001'), - selectField('Waehrung', payoutForm.payout_currency, ['DOGE'].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== 'DOGE')), (value) => setPayoutForm({ ...payoutForm, payout_currency: value })), + selectField('Waehrung', payoutForm.payout_currency, [currentCoinCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentCoinCurrency)), (value) => setPayoutForm({ ...payoutForm, payout_currency: value })), textareaField('Notiz', payoutForm.note, (value) => setPayoutForm({ ...payoutForm, note: value })), h('div', { className: 'mc-inline-row' }, [ h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPayoutModalOpen(false) }, 'Abbrechen'), diff --git a/modules/mining-checker/design.json b/modules/mining-checker/design.json index 8b82916..84c28b2 100644 --- a/modules/mining-checker/design.json +++ b/modules/mining-checker/design.json @@ -8,6 +8,7 @@ "sections": [ { "key": "overview", "label": "Ueberblick" }, { "key": "measurements", "label": "Messpunkte" }, + { "key": "wallet", "label": "Wallet" }, { "key": "mining", "label": "Mining" }, { "key": "dashboards", "label": "Dashboards" }, { "key": "settings", "label": "Settings" } diff --git a/modules/mining-checker/sql/migrations/001_init.sql b/modules/mining-checker/sql/migrations/001_init.sql index 3589f1c..5a1fba9 100644 --- a/modules/mining-checker/sql/migrations/001_init.sql +++ b/modules/mining-checker/sql/migrations/001_init.sql @@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( project_key VARCHAR(64) NOT NULL, measured_at DATETIME NOT NULL, coins_total DECIMAL(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', price_per_coin DECIMAL(20,8) NULL, price_currency VARCHAR(10) NULL, note TEXT NULL, @@ -84,6 +85,28 @@ CREATE TABLE IF NOT EXISTS miningcheck_targets ( CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label) ); +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount DECIMAL(20,8) NULL, + total_value_currency VARCHAR(10) NULL, + wallet_balance DECIMAL(28,10) NULL, + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSON NULL, + note TEXT NULL, + source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) +); + CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/modules/mining-checker/sql/schema.mysql.sql b/modules/mining-checker/sql/schema.mysql.sql index bb0165c..7f83b18 100644 --- a/modules/mining-checker/sql/schema.mysql.sql +++ b/modules/mining-checker/sql/schema.mysql.sql @@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( project_key VARCHAR(64) NOT NULL, measured_at DATETIME NOT NULL, coins_total DECIMAL(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', price_per_coin DECIMAL(20,8) NULL, price_currency VARCHAR(10) NULL, fx_fetch_id BIGINT UNSIGNED NULL, @@ -103,6 +104,28 @@ CREATE TABLE IF NOT EXISTS miningcheck_payouts ( KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at) ); +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount DECIMAL(20,8) NULL, + total_value_currency VARCHAR(10) NULL, + wallet_balance DECIMAL(28,10) NULL, + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSON NULL, + note TEXT NULL, + source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) +); + CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/modules/mining-checker/sql/schema.pgsql.sql b/modules/mining-checker/sql/schema.pgsql.sql index 0c4f12b..232489d 100644 --- a/modules/mining-checker/sql/schema.pgsql.sql +++ b/modules/mining-checker/sql/schema.pgsql.sql @@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( owner_sub VARCHAR(128) NOT NULL, measured_at TIMESTAMP NOT NULL, coins_total NUMERIC(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', price_per_coin NUMERIC(20,8), price_currency VARCHAR(10), fx_fetch_id BIGINT, @@ -112,6 +113,30 @@ CREATE TABLE IF NOT EXISTS miningcheck_payouts ( CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at ON miningcheck_payouts(project_key, owner_sub, payout_at); +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount NUMERIC(20,8), + total_value_currency VARCHAR(10), + wallet_balance NUMERIC(28,10), + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSONB, + note TEXT, + source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')), + image_path VARCHAR(255), + ocr_raw_text TEXT, + ocr_confidence NUMERIC(6,4), + ocr_flags JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at + ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at); + CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( id BIGSERIAL PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/modules/mining-checker/sql/schema.sql b/modules/mining-checker/sql/schema.sql index bb0165c..7f83b18 100644 --- a/modules/mining-checker/sql/schema.sql +++ b/modules/mining-checker/sql/schema.sql @@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( project_key VARCHAR(64) NOT NULL, measured_at DATETIME NOT NULL, coins_total DECIMAL(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', price_per_coin DECIMAL(20,8) NULL, price_currency VARCHAR(10) NULL, fx_fetch_id BIGINT UNSIGNED NULL, @@ -103,6 +104,28 @@ CREATE TABLE IF NOT EXISTS miningcheck_payouts ( KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at) ); +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount DECIMAL(20,8) NULL, + total_value_currency VARCHAR(10) NULL, + wallet_balance DECIMAL(28,10) NULL, + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSON NULL, + note TEXT NULL, + source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) +); + CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/modules/mining-checker/src/Api/Router.php b/modules/mining-checker/src/Api/Router.php index c84b8ad..3d40e9c 100644 --- a/modules/mining-checker/src/Api/Router.php +++ b/modules/mining-checker/src/Api/Router.php @@ -253,6 +253,15 @@ final class Router Http::json(['data' => $this->savePayout($projectKey, Http::input())], 201); } + if ($resource === 'wallet-snapshots' && $method === 'GET') { + Http::json(['data' => $this->walletSnapshots($projectKey)]); + } + + if ($resource === 'wallet-snapshots' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveWalletSnapshot($projectKey, Http::input())], 201); + } + if ($resource === 'miner-offers' && $method === 'GET') { Http::json(['data' => $this->minerOffers($projectKey)]); } @@ -329,6 +338,10 @@ final class Router 'purchased_miners' => [], 'measurement_rates' => [], ], ['project_key' => $projectKey]); + $walletSnapshots = $this->safeTimed('bootstrap.wallet_snapshots', fn () => $this->bootstrapWalletSnapshots($projectKey, $view), [], [ + 'project_key' => $projectKey, + 'view' => $view, + ]); $measurements = $this->safeTimed('bootstrap.measurements', fn () => $this->bootstrapMeasurements($projectKey, $settings, $view), [], [ 'project_key' => $projectKey, 'view' => $view, @@ -369,6 +382,7 @@ final class Router return [ 'project' => $this->repository()->getProject($projectKey), 'settings' => $settings, + 'wallet_snapshots' => $walletSnapshots, 'measurements' => $measurements, 'targets' => $targets, 'dashboards' => $dashboards, @@ -483,6 +497,7 @@ final class Router 'measurements' => $this->safeRead(fn () => $this->repository()->listAllMeasurements($projectKey), []), 'measurement_rates' => $this->safeRead(fn () => $this->repository()->listMeasurementRates($projectKey), []), 'payouts' => $this->safeRead(fn () => $this->repository()->listPayouts($projectKey), []), + 'wallet_snapshots' => $this->safeRead(fn () => $this->repository()->listWalletSnapshots($projectKey, 500), []), 'targets' => $this->safeRead(fn () => $this->repository()->listTargets($projectKey), []), 'dashboards' => $this->safeRead(fn () => $this->repository()->listDashboards($projectKey), []), 'miner_offers' => $this->safeRead(fn () => $this->repository()->listMinerOffers($projectKey), []), @@ -587,6 +602,23 @@ final class Router ]); } + foreach ($backup['wallet_snapshots'] as $snapshot) { + $this->repository()->saveWalletSnapshot($projectKey, [ + 'measured_at' => $snapshot['measured_at'], + 'total_value_amount' => $snapshot['total_value_amount'] ?? null, + 'total_value_currency' => $snapshot['total_value_currency'] ?? null, + 'wallet_balance' => $snapshot['wallet_balance'] ?? null, + 'wallet_currency' => $snapshot['wallet_currency'] ?? ($backup['settings']['crypto_currency'] ?? 'DOGE'), + 'balances_json' => $snapshot['balances_json'] ?? [], + 'note' => $snapshot['note'] ?? null, + 'source' => $snapshot['source'] ?? 'manual', + 'image_path' => $snapshot['image_path'] ?? null, + 'ocr_raw_text' => $snapshot['ocr_raw_text'] ?? null, + 'ocr_confidence' => $snapshot['ocr_confidence'] ?? null, + 'ocr_flags' => $snapshot['ocr_flags'] ?? [], + ]); + } + $minerOfferIdMap = []; foreach ($backup['miner_offers'] as $offer) { $savedOffer = $this->repository()->saveMinerOffer($projectKey, [ @@ -694,6 +726,7 @@ final class Router 'purchased_miners' => count($backup['purchased_miners']), 'cost_plans' => count($backup['cost_plans']), 'payouts' => count($backup['payouts']), + 'wallet_snapshots' => count($backup['wallet_snapshots']), 'targets' => count($backup['targets']), 'dashboards' => count($backup['dashboards']), 'miner_offers' => count($backup['miner_offers']), @@ -1191,7 +1224,7 @@ final class Router private function bootstrapMeasurements(string $projectKey, array $settings, string $view): array { - if (in_array($view, ['settings', 'dashboards'], true)) { + if (in_array($view, ['settings', 'dashboards', 'wallet'], true)) { return []; } @@ -1212,6 +1245,15 @@ final class Router return $this->analytics()->enrichMeasurements($rows, $settings); } + private function bootstrapWalletSnapshots(string $projectKey, string $view): array + { + if (!in_array($view, ['wallet'], true)) { + return []; + } + + return $this->repository()->listWalletSnapshots($projectKey, 50); + } + private function bootstrapTargets(string $projectKey, string $view): array { return in_array($view, ['overview', 'mining'], true) ? $this->targets($projectKey) : []; @@ -1285,6 +1327,7 @@ final class Router ? $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $projectTimezone) : $this->currentTimestamp(), 'coins_total' => $this->requiredDecimal($input['coins_total'] ?? null, 'coins_total'), + 'coin_currency' => $this->measurementCoinCurrency($projectKey, $input), 'price_per_coin' => $this->optionalDecimal($input['price_per_coin'] ?? null), 'price_currency' => $this->optionalCurrency($input['price_currency'] ?? null), 'note' => $this->optionalString($input['note'] ?? null, 2000), @@ -1330,7 +1373,7 @@ final class Router } try { - $payload = $this->parseImportLine($trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey)); + $payload = $this->parseImportLine($projectKey, $trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey)); $this->syncCurrencyCatalogForMeasurement($payload); $payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, false); $result = $this->repository()->createMeasurementIfNotExists($projectKey, $payload); @@ -1368,6 +1411,15 @@ final class Router private function syncCurrencyCatalogForMeasurement(array $payload): void { + $coinCurrency = strtoupper(trim((string) ($payload['coin_currency'] ?? ''))); + if ($coinCurrency !== '' && $this->currencyCatalogEntry($coinCurrency) === null) { + throw new ApiException( + 'Coin-Waehrung ist im fx-rates Katalog nicht vorhanden.', + 422, + ['currency' => $coinCurrency] + ); + } + $priceCurrency = strtoupper(trim((string) ($payload['price_currency'] ?? ''))); if ($priceCurrency === '') { return; @@ -1382,7 +1434,7 @@ final class Router } } - private function parseImportLine(string $line, ?string $defaultCurrency, string $defaultSource, string $projectTimezone): array + private function parseImportLine(string $projectKey, string $line, ?string $defaultCurrency, string $defaultSource, string $projectTimezone): array { $parts = array_map('trim', explode('|', $line)); if (count($parts) < 2) { @@ -1402,6 +1454,7 @@ final class Router return [ 'measured_at' => $measuredAt, 'coins_total' => $coinsTotal, + 'coin_currency' => $this->measurementCoinCurrency($projectKey), 'price_per_coin' => $pricePerCoin, 'price_currency' => $priceCurrency, 'note' => $note, @@ -1629,6 +1682,36 @@ final class Router return $this->repository()->savePayout($projectKey, $payload); } + private function walletSnapshots(string $projectKey): array + { + return $this->repository()->listWalletSnapshots($projectKey, 100); + } + + private function saveWalletSnapshot(string $projectKey, array $input): array + { + $balances = $input['balances_json'] ?? []; + if (!is_array($balances)) { + $balances = []; + } + + $payload = [ + 'measured_at' => $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $this->projectTimezone($projectKey)), + 'total_value_amount' => $this->optionalDecimal($input['total_value_amount'] ?? null), + 'total_value_currency' => $this->optionalCurrency($input['total_value_currency'] ?? null), + 'wallet_balance' => $this->optionalDecimal($input['wallet_balance'] ?? null), + 'wallet_currency' => $this->requiredCurrency($input['wallet_currency'] ?? ($this->settings($projectKey)['crypto_currency'] ?? 'DOGE'), 'wallet_currency'), + 'balances_json' => $balances, + 'note' => $this->optionalString($input['note'] ?? null, 1000), + 'source' => $this->enumValue($input['source'] ?? 'manual', ['manual', 'image_ocr', 'seed_import'], 'source'), + 'image_path' => $this->optionalString($input['image_path'] ?? null, 255), + 'ocr_raw_text' => $this->optionalString($input['ocr_raw_text'] ?? null, 20000), + 'ocr_confidence' => $this->optionalDecimal($input['ocr_confidence'] ?? null), + 'ocr_flags' => is_array($input['ocr_flags'] ?? null) ? $input['ocr_flags'] : [], + ]; + + return $this->repository()->saveWalletSnapshot($projectKey, $payload); + } + private function saveMinerOffer(string $projectKey, array $input): array { $payload = [ @@ -1658,9 +1741,16 @@ final class Router throw new ApiException('Miner-Angebot nicht gefunden.', 404); } + $settings = $this->settings($projectKey); + $cryptoCurrency = $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency'); + $isAutoRenew = array_key_exists('auto_renew', $input) ? !empty($input['auto_renew']) : !empty($offer['auto_renew']); + $purchaseCurrency = $this->optionalCurrency($input['currency'] ?? null) ?? (string) ($offer['effective_price_currency'] ?? $offer['price_currency'] ?? $offer['base_price_currency'] ?? ''); + if (!$isAutoRenew) { + $purchaseCurrency = $cryptoCurrency; + } $purchaseCost = $this->optionalDecimal($input['total_cost_amount'] ?? null); - if ($purchaseCost === null) { + if ($purchaseCost === null || !$isAutoRenew) { $purchaseCost = $this->resolveOfferPurchaseCost(array_merge($offer, [ 'price_currency' => $purchaseCurrency !== '' ? $purchaseCurrency : ($offer['price_currency'] ?? ''), ])); @@ -1684,7 +1774,7 @@ final class Router 'usd_reference_amount' => $offer['usd_reference_amount'] ?? null, 'reference_price_amount' => $referencePriceAmount, 'reference_price_currency' => $referencePriceCurrency, - 'auto_renew' => array_key_exists('auto_renew', $input) ? (!empty($input['auto_renew']) ? 1 : 0) : (!empty($offer['auto_renew']) ? 1 : 0), + 'auto_renew' => $isAutoRenew ? 1 : 0, 'note' => $this->optionalString($input['note'] ?? ($offer['note'] ?? null), 1000), 'is_active' => 1, ]); @@ -2171,6 +2261,17 @@ final class Router return null; } + private function measurementCoinCurrency(string $projectKey, array $input = []): string + { + $requested = $this->optionalCurrency($input['coin_currency'] ?? null); + if ($requested !== null) { + return $requested; + } + + $settings = $this->settings($projectKey); + return $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency'); + } + private function ensureMeasurementFxReferences(string $projectKey, array $rows, ?array $settings = null): array { $maxAgeHours = self::FX_FETCH_MAX_AGE_HOURS; diff --git a/modules/mining-checker/src/Domain/AnalyticsService.php b/modules/mining-checker/src/Domain/AnalyticsService.php index b0218e9..b4a684d 100644 --- a/modules/mining-checker/src/Domain/AnalyticsService.php +++ b/modules/mining-checker/src/Domain/AnalyticsService.php @@ -34,23 +34,26 @@ final class AnalyticsService $previousIntervalRate = null; $result = []; $payoutIndex = 0; - $cumulativePayouts = 0.0; $lastPayoutTs = null; + $payoutsByAsset = []; $latestPriceByCurrency = []; foreach ($measurements as $row) { $measuredTs = $this->utcTimestamp((string) $row['measured_at']); + $coinCurrency = strtoupper(trim((string) ($row['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE'))); 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); + $payoutAsset = strtoupper(trim((string) ($payouts[$payoutIndex]['payout_currency'] ?? $coinCurrency))); + $payoutsByAsset[$payoutAsset] = ($payoutsByAsset[$payoutAsset] ?? 0.0) + (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0); $lastPayoutTs = $payoutTs; $payoutIndex++; } + $cumulativePayouts = (float) ($payoutsByAsset[$coinCurrency] ?? 0.0); $visibleCoinsTotal = (float) $row['coins_total']; $effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts; $growth = $effectiveCoinsTotal - $baselineCoins; @@ -120,7 +123,7 @@ final class AnalyticsService } if ($price === null) { foreach (['USD', 'EUR'] as $fallbackCurrency) { - $fxPrice = $this->convertAmount(1.0, 'DOGE', $fallbackCurrency, $row); + $fxPrice = $this->convertAmount(1.0, $coinCurrency, $fallbackCurrency, $row); if ($fxPrice !== null && $fxPrice > 0) { $latestPriceByCurrency[$fallbackCurrency] = $fxPrice; } @@ -159,6 +162,7 @@ final class AnalyticsService $result[] = array_merge($row, [ 'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6), 'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6), + 'coin_currency' => $coinCurrency, 'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6), 'growth_since_baseline' => $this->roundOrNull($growth, 6), 'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4), @@ -296,26 +300,60 @@ final class AnalyticsService } $latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''); - $investedCapital = $latestCurrency !== '' + $latestMeasuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); + $cashInvestedCapital = $latestCurrency !== '' ? $this->totalInvestmentBasis( is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], $purchasedMiners, - $this->utcTimestamp((string) ($latest['measured_at'] ?? '')), + $latestMeasuredTs, $latestCurrency, - $latest + $latest, + 'cash' ) : null; + $reinvestedCapital = $latestCurrency !== '' + ? $this->totalInvestmentBasis( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latestMeasuredTs, + $latestCurrency, + $latest, + 'reinvest' + ) + : null; + $walletBalances = $this->walletBalances($payouts, $purchasedMiners, $latestMeasuredTs); + $walletBalanceCurrentAsset = (float) ($walletBalances[strtoupper(trim((string) ($latest['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')))] ?? 0.0); + $holdingsCurrentAsset = $walletBalanceCurrentAsset + (float) ($latest['coins_total_visible'] ?? $latest['coins_total'] ?? 0); + $walletValue = $latestCurrency !== '' ? $this->walletBalanceValue($walletBalances, $latestCurrency, $latest) : null; + $currentVisibleValue = is_numeric($latest['current_value'] ?? null) ? (float) $latest['current_value'] : null; + $totalHoldingsValue = ($walletValue !== null || $currentVisibleValue !== null) + ? (float) ($walletValue ?? 0.0) + (float) ($currentVisibleValue ?? 0.0) + : 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; + $breakEvenRemainingAmount = ($cashInvestedCapital !== null && $totalHoldingsValue !== null) + ? max(0.0, $cashInvestedCapital - $totalHoldingsValue) + : $cashInvestedCapital; + $breakEvenProjection = ( + $cashInvestedCapital !== null && + $breakEvenRemainingAmount !== null + ) ? $this->projectBreakEvenDate( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latest, + $breakEvenRemainingAmount + ) : ['days' => null, 'eta' => null]; + $breakEvenDaysOverall = is_numeric($breakEvenProjection['days'] ?? null) ? (float) $breakEvenProjection['days'] : null; $latestSummary = array_merge($latest, [ - 'invested_capital' => $this->roundOrNull($investedCapital, 8), + 'invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), + 'cash_invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), + 'reinvested_capital' => $this->roundOrNull($reinvestedCapital, 8), + 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), + 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), + 'wallet_value' => $this->roundOrNull($walletValue, 8), + 'total_holdings_value' => $this->roundOrNull($totalHoldingsValue, 8), 'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8), 'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4), + 'break_even_eta_at' => $breakEvenProjection['eta'] ?? null, ]); $currentProjection = $this->projectPerformance( is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], @@ -349,6 +387,9 @@ final class AnalyticsService '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), + 'wallet_balances' => $walletBalances, + 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), + 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), ], 'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4), 'miner_offers' => $offerSummary, @@ -628,7 +669,7 @@ final class AnalyticsService if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { continue; } - if (!$this->entryIsCovered($miner, $measurementTs > 0 ? $measurementTs : null, 'purchased_at')) { + if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) { continue; } @@ -650,7 +691,7 @@ final class AnalyticsService return $matched ? $total : null; } - private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency, ?array $fxContext = null): ?float + private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency, ?array $fxContext = null, ?string $fundingSource = null): ?float { $target = strtoupper(trim($targetCurrency)); if ($target === '') { @@ -661,26 +702,16 @@ final class AnalyticsService $matched = false; $purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs, $fxContext); - if ($purchasedTotal !== null) { + if ($fundingSource === null && $purchasedTotal !== null) { $matched = true; $total += $purchasedTotal; } foreach ($costPlans as $plan) { - if (empty($plan['is_active'])) { + if ($measurementTs > 0 && $this->utcTimestamp((string) ($plan['starts_at'] ?? '')) > $measurementTs) { 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) { + if ($fundingSource !== null && $this->entryFundingSource($plan) !== $fundingSource) { continue; } @@ -699,6 +730,31 @@ final class AnalyticsService $total += $converted; } + if ($fundingSource !== null) { + foreach ($purchasedMiners as $miner) { + if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) { + continue; + } + if ($this->entryFundingSource($miner) !== $fundingSource) { + continue; + } + + $amount = $this->investmentBasisAmount($miner); + $currency = strtoupper(trim((string) ($miner['reference_price_currency'] ?? $miner['currency'] ?? ''))); + if ($amount === null || $amount <= 0 || $currency === '') { + continue; + } + + $converted = $this->convertAmount($amount, $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + } + return $matched ? $total : null; } @@ -1014,6 +1070,153 @@ final class AnalyticsService return $endIndex - $startIndex + 1; } + private function walletBalances(array $payouts, array $purchasedMiners, int $measurementTs): array + { + $balances = []; + + foreach ($payouts as $payout) { + $payoutTs = $this->utcTimestamp((string) ($payout['payout_at'] ?? '')); + if ($measurementTs > 0 && $payoutTs > $measurementTs) { + continue; + } + + $currency = strtoupper(trim((string) ($payout['payout_currency'] ?? ''))); + $amount = is_numeric($payout['coins_amount'] ?? null) ? (float) $payout['coins_amount'] : null; + if ($currency === '' || $amount === null) { + continue; + } + + $balances[$currency] = ($balances[$currency] ?? 0.0) + $amount; + } + + foreach ($purchasedMiners as $miner) { + $purchaseTs = $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')); + if ($measurementTs > 0 && $purchaseTs > $measurementTs) { + continue; + } + if ($this->entryFundingSource($miner) !== 'reinvest') { + continue; + } + + $currency = strtoupper(trim((string) ($miner['currency'] ?? ''))); + $amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null; + if ($currency === '' || $amount === null) { + continue; + } + + $balances[$currency] = ($balances[$currency] ?? 0.0) - $amount; + } + + ksort($balances); + return array_map(fn (float $value): float => round($value, 8), $balances); + } + + private function walletBalanceValue(array $balances, string $targetCurrency, ?array $fxContext = null): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '' || $balances === []) { + return null; + } + + $total = 0.0; + $matched = false; + foreach ($balances as $currency => $amount) { + if (!is_numeric($amount)) { + continue; + } + $numericAmount = (float) $amount; + if (strtoupper((string) $currency) === $target) { + $matched = true; + $total += $numericAmount; + continue; + } + + $converted = $this->convertAmount($numericAmount, (string) $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + + return $matched ? $total : null; + } + + private function entryFundingSource(array $entry): string + { + return !empty($entry['auto_renew']) ? 'cash' : 'reinvest'; + } + + private function investmentBasisAmount(array $entry): ?float + { + if (is_numeric($entry['reference_price_amount'] ?? null)) { + return (float) $entry['reference_price_amount']; + } + if (is_numeric($entry['base_price_amount'] ?? null)) { + return (float) $entry['base_price_amount']; + } + if (is_numeric($entry['total_cost_amount'] ?? null)) { + return (float) $entry['total_cost_amount']; + } + return null; + } + + private function projectBreakEvenDate(array $costPlans, array $purchasedMiners, array $latest, float $remainingAmount): array + { + if ($remainingAmount <= 0) { + return ['days' => 0.0, 'eta' => (string) ($latest['measured_at'] ?? null)]; + } + + $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 ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $baseTs <= 0) { + return ['days' => null, 'eta' => null]; + } + + $dogePerDayPerMh = $dogePerDay / $currentHashrateMh; + $cumulativeRevenue = 0.0; + $maxDays = 3650; + + for ($day = 0; $day <= $maxDays; $day++) { + $dayHashrate = 0.0; + $dayTs = $baseTs + ($day * 86400); + foreach ($costPlans as $plan) { + if (!empty($plan['is_active']) && $this->entryIsCovered($plan, $dayTs)) { + $dayHashrate += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null); + $dayHashrate += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null); + } + } + foreach ($purchasedMiners as $miner) { + if ((array_key_exists('is_active', $miner) && empty($miner['is_active'])) || !$this->entryIsCovered($miner, $dayTs, 'purchased_at')) { + continue; + } + $dayHashrate += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null); + $dayHashrate += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null); + } + + if ($dayHashrate <= 0) { + continue; + } + + $dayRevenue = $dayHashrate * $dogePerDayPerMh * $pricePerCoin; + $cumulativeRevenue += $dayRevenue; + if ($cumulativeRevenue >= $remainingAmount) { + $etaTs = (int) round($baseTs + ($day * 86400)); + return [ + 'days' => (float) $day, + 'eta' => $this->formatUtcTimestamp($etaTs), + ]; + } + } + + return ['days' => null, 'eta' => null]; + } + private function utcTimestamp(?string $value): int { $normalized = trim((string) $value); diff --git a/modules/mining-checker/src/Domain/OcrService.php b/modules/mining-checker/src/Domain/OcrService.php index a70e3bd..2854ae4 100644 --- a/modules/mining-checker/src/Domain/OcrService.php +++ b/modules/mining-checker/src/Domain/OcrService.php @@ -46,7 +46,11 @@ final class OcrService $flags[] = 'ocr_hint_text_used'; } - $parsed = $this->parseText($rawText, (string) ($input['date_context'] ?? date('Y-m-d'))); + $parsed = $this->parseText( + $rawText, + (string) ($input['date_context'] ?? date('Y-m-d')), + strtoupper(trim((string) ($input['wallet_currency_hint'] ?? ''))) + ); $parsed['image_path'] = $targetFile; $parsed['raw_text'] = $rawText; $parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags']))); @@ -291,7 +295,27 @@ final class OcrService return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== ''; } - private function parseText(string $rawText, string $dateContext): array + private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array + { + $measurement = $this->parseMeasurementText($rawText, $dateContext); + $wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint); + + $isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0) + && ( + ($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null + || ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null + ); + + return [ + 'kind' => $isWallet ? 'wallet' : 'measurement', + 'suggested' => $measurement['suggested'], + 'suggested_wallet' => $wallet['suggested_wallet'], + 'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4), + 'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'], + ]; + } + + private function parseMeasurementText(string $rawText, string $dateContext): array { $flags = []; $suggestedTime = null; @@ -433,6 +457,107 @@ final class OcrService ], 'confidence' => round($confidence, 4), 'flags' => $flags, + 'score' => $matchedFields, + ]; + } + + private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array + { + $flags = []; + $suggestedTime = null; + $totalValueAmount = null; + $totalValueCurrency = null; + $walletBalance = null; + $walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE'; + $balances = []; + + $normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: ''; + $lines = array_values(array_filter(array_map( + static fn (string $line): string => trim($line), + preg_split('/\R/u', $rawText) ?: [] + ), static fn (string $line): bool => $line !== '')); + + 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]); + } + + if ( + preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch) + || preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch) + ) { + $totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8); + $totalValueCurrency = strtoupper((string) $totalMatch[2]); + } else { + $flags[] = 'wallet_total_missing'; + } + + preg_match_all('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $normalizedText, $balanceMatches, PREG_SET_ORDER); + foreach ($balanceMatches as $match) { + $amount = round((float) str_replace(',', '.', $match[1]), 10); + $currency = strtoupper((string) $match[2]); + if ($amount <= 0 || $currency === '') { + continue; + } + if (!isset($balances[$currency]) || $amount > (float) $balances[$currency]) { + $balances[$currency] = $amount; + } + } + + if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) { + $walletCurrency = $walletCurrencyHint; + $walletBalance = (float) $balances[$walletCurrencyHint]; + } elseif ($balances !== []) { + foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) { + if (array_key_exists($preferredCurrency, $balances)) { + $walletCurrency = $preferredCurrency; + $walletBalance = (float) $balances[$preferredCurrency]; + break; + } + } + if ($walletBalance === null) { + $firstCurrency = array_key_first($balances); + if (is_string($firstCurrency)) { + $walletCurrency = $firstCurrency; + $walletBalance = (float) $balances[$firstCurrency]; + } + } + } else { + $flags[] = 'wallet_balance_missing'; + } + + $walletIndicators = 0; + $normalizedLower = strtolower($normalizedText); + foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) { + if (str_contains($normalizedLower, $indicator)) { + $walletIndicators++; + } + } + + $matchedFields = 0; + foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) { + if ($field !== null && $field !== '') { + $matchedFields++; + } + } + $score = $matchedFields + ($walletIndicators * 2); + $confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03))); + + ksort($balances); + + return [ + 'suggested_wallet' => [ + 'measured_at' => $suggestedTime, + 'total_value_amount' => $totalValueAmount, + 'total_value_currency' => $totalValueCurrency, + 'wallet_balance' => $walletBalance, + 'wallet_currency' => $walletCurrency, + 'balances_json' => $balances, + 'note' => null, + 'source' => 'image_ocr', + ], + 'confidence' => round($confidence, 4), + 'flags' => array_values(array_unique($flags)), + 'score' => $score, ]; } } diff --git a/modules/mining-checker/src/Infrastructure/MiningRepository.php b/modules/mining-checker/src/Infrastructure/MiningRepository.php index b5ca463..a4267bd 100644 --- a/modules/mining-checker/src/Infrastructure/MiningRepository.php +++ b/modules/mining-checker/src/Infrastructure/MiningRepository.php @@ -229,6 +229,7 @@ final class MiningRepository 'owner_sub' => $this->ownerSub, 'measured_at' => $payload['measured_at'], 'coins_total' => $payload['coins_total'], + 'coin_currency' => $payload['coin_currency'] ?? 'DOGE', 'price_per_coin' => $payload['price_per_coin'], 'price_currency' => $payload['price_currency'], 'fx_fetch_id' => $payload['fx_fetch_id'] ?? null, @@ -243,10 +244,10 @@ final class MiningRepository if ($this->driver === 'pgsql') { $stmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('measurements') . ' ( - project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note, + project_key, owner_sub, measured_at, coins_total, coin_currency, price_per_coin, price_currency, fx_fetch_id, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags ) VALUES ( - :project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note, + :project_key, :owner_sub, :measured_at, :coins_total, :coin_currency, :price_per_coin, :price_currency, :fx_fetch_id, :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb) ) RETURNING *' @@ -259,10 +260,10 @@ final class MiningRepository $stmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('measurements') . ' ( - project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note, + project_key, owner_sub, measured_at, coins_total, coin_currency, price_per_coin, price_currency, fx_fetch_id, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags ) VALUES ( - :project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note, + :project_key, :owner_sub, :measured_at, :coins_total, :coin_currency, :price_per_coin, :price_currency, :fx_fetch_id, :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags )' ); @@ -441,6 +442,71 @@ final class MiningRepository return $this->normalizeRow($fetch->fetch() ?: []); } + public function listWalletSnapshots(string $projectKey, int $limit = 100): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('wallet_snapshots') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY measured_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 saveWalletSnapshot(string $projectKey, array $payload): array + { + $params = [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'measured_at' => $payload['measured_at'], + 'total_value_amount' => $payload['total_value_amount'] ?? null, + 'total_value_currency' => $payload['total_value_currency'] ?? null, + 'wallet_balance' => $payload['wallet_balance'] ?? null, + 'wallet_currency' => $payload['wallet_currency'], + 'balances_json' => json_encode($payload['balances_json'] ?? [], JSON_UNESCAPED_UNICODE), + 'note' => $payload['note'] ?? null, + 'source' => $payload['source'] ?? 'manual', + 'image_path' => $payload['image_path'] ?? null, + 'ocr_raw_text' => $payload['ocr_raw_text'] ?? null, + 'ocr_confidence' => $payload['ocr_confidence'] ?? null, + 'ocr_flags' => json_encode($payload['ocr_flags'] ?? [], JSON_UNESCAPED_UNICODE), + ]; + + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('wallet_snapshots') . ' ( + project_key, owner_sub, measured_at, total_value_amount, total_value_currency, wallet_balance, + wallet_currency, balances_json, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :total_value_amount, :total_value_currency, :wallet_balance, + :wallet_currency, CAST(:balances_json AS jsonb), :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb) + ) + RETURNING *' + ); + $stmt->execute($params); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('wallet_snapshots') . ' ( + project_key, owner_sub, measured_at, total_value_amount, total_value_currency, wallet_balance, + wallet_currency, balances_json, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :total_value_amount, :total_value_currency, :wallet_balance, + :wallet_currency, :balances_json, :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags + )' + ); + $stmt->execute($params); + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('wallet_snapshots') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + public function listTargets(string $projectKey): array { $stmt = $this->pdo->prepare( @@ -1195,6 +1261,7 @@ final class MiningRepository 'fx_rates' => $this->prefix . 'fx_rates', 'measurement_rates' => $this->prefix . 'measurement_rates', 'payouts' => $this->prefix . 'payouts', + 'wallet_snapshots' => $this->prefix . 'wallet_snapshots', 'miner_offers' => $this->prefix . 'miner_offers', 'purchased_miners' => $this->prefix . 'purchased_miners', default => throw new \RuntimeException('Unknown mining table: ' . $logicalName), @@ -1273,7 +1340,7 @@ final class MiningRepository private function normalizeRow(array $row): array { - foreach (['ocr_flags', 'filters_json', 'preferred_currencies'] as $jsonField) { + foreach (['ocr_flags', 'filters_json', 'preferred_currencies', 'balances_json'] as $jsonField) { if (array_key_exists($jsonField, $row) && is_string($row[$jsonField]) && trim($row[$jsonField]) !== '') { $decoded = json_decode($row[$jsonField], true); if (json_last_error() === JSON_ERROR_NONE) { diff --git a/modules/mining-checker/src/Infrastructure/SchemaManager.php b/modules/mining-checker/src/Infrastructure/SchemaManager.php index cd4102f..c4c4025 100644 --- a/modules/mining-checker/src/Infrastructure/SchemaManager.php +++ b/modules/mining-checker/src/Infrastructure/SchemaManager.php @@ -188,6 +188,10 @@ final class SchemaManager $this->ensureMeasurementFxReferenceColumn(); $applied[] = 'measurement_fx_reference'; } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) { + $this->ensureMeasurementCoinCurrencyColumn(); + $applied[] = 'measurement_coin_currency'; + } if (!$this->tableExists($this->prefix . 'measurement_rates')) { $this->ensureMeasurementRatesTable(); @@ -197,6 +201,10 @@ final class SchemaManager $this->ensurePayoutsTable(); $applied[] = 'payouts_table'; } + if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { + $this->ensureWalletSnapshotsTable(); + $applied[] = 'wallet_snapshots_table'; + } if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { $this->ensureFxRatesTable(); $applied[] = 'fx_rates_table'; @@ -293,6 +301,10 @@ final class SchemaManager $this->ensureMeasurementFxReferenceColumn(); $applied[] = 'measurement_fx_reference'; } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) { + $this->ensureMeasurementCoinCurrencyColumn(); + $applied[] = 'measurement_coin_currency'; + } if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { $this->ensureFxRatesTable(); @@ -308,6 +320,10 @@ final class SchemaManager $this->ensurePayoutsTable(); $applied[] = 'payouts_table'; } + if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { + $this->ensureWalletSnapshotsTable(); + $applied[] = 'wallet_snapshots_table'; + } if (!$this->tableExists($this->prefix . 'miner_offers')) { $this->upgradeMinerOffersTable(); @@ -421,6 +437,54 @@ final class SchemaManager } } + private function ensureMeasurementCoinCurrencyColumn(): void + { + $table = $this->prefix . 'measurements'; + $settingsTable = $this->prefix . 'settings'; + if (!$this->tableExists($table)) { + return; + } + + $statements = $this->driver === 'pgsql' + ? [ + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS coin_currency VARCHAR(10)', + 'UPDATE ' . $table . ' AS m + SET coin_currency = COALESCE(NULLIF(BTRIM(s.crypto_currency), \'\'), \'DOGE\') + FROM ' . $settingsTable . ' AS s + WHERE s.project_key = m.project_key + AND (m.coin_currency IS NULL OR BTRIM(m.coin_currency) = \'\')', + 'UPDATE ' . $table . ' SET coin_currency = \'DOGE\' WHERE coin_currency IS NULL OR BTRIM(coin_currency) = \'\'', + 'ALTER TABLE ' . $table . ' ALTER COLUMN coin_currency SET DEFAULT \'DOGE\'', + 'ALTER TABLE ' . $table . ' ALTER COLUMN coin_currency SET NOT NULL', + ] + : [ + 'ALTER TABLE `' . $table . '` ADD COLUMN coin_currency VARCHAR(10) NOT NULL DEFAULT \'DOGE\' AFTER coins_total', + 'UPDATE `' . $table . '` m + LEFT JOIN `' . $settingsTable . '` s ON s.project_key = m.project_key + SET m.coin_currency = COALESCE(NULLIF(TRIM(s.crypto_currency), \'\'), \'DOGE\') + WHERE m.coin_currency IS NULL OR TRIM(m.coin_currency) = \'\'', + ]; + + foreach ($statements as $index => $statement) { + try { + if ($this->driver === 'mysql' && $index === 0 && $this->columnExists($table, 'coin_currency')) { + continue; + } + $this->executeUpgradeStatements([$statement], 'Messpunkt-Coin-Waehrung konnte nicht angelegt werden.'); + } catch (\Throwable $exception) { + $message = strtolower($exception->getMessage()); + if ( + ($this->driver === 'mysql' && $index === 0 && str_contains($message, 'duplicate column')) || + ($this->driver === 'pgsql' && str_contains($message, 'already exists')) + ) { + continue; + } + + throw $exception; + } + } + } + private function ensureLegacyMinerOfferImportColumns(): void { $table = $this->prefix . 'miner_offers'; @@ -599,6 +663,9 @@ final class SchemaManager if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { $upgrades[] = 'measurement_fx_reference'; } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) { + $upgrades[] = 'measurement_coin_currency'; + } if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { $upgrades[] = 'fx_rates_table'; @@ -609,6 +676,9 @@ final class SchemaManager if (!$this->tableExists($this->prefix . 'payouts')) { $upgrades[] = 'payouts_table'; } + if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { + $upgrades[] = 'wallet_snapshots_table'; + } if (!$this->tableExists($this->prefix . 'miner_offers')) { $upgrades[] = 'miner_offers_table'; } @@ -849,6 +919,65 @@ final class SchemaManager $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Auszahlungen fehlgeschlagen.'); } + public function ensureWalletSnapshotsTable(): void + { + if ($this->tableExists($this->prefix . 'wallet_snapshots')) { + return; + } + + $table = $this->prefix . 'wallet_snapshots'; + $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(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount NUMERIC(20,8), + total_value_currency VARCHAR(10), + wallet_balance NUMERIC(28,10), + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSONB, + note TEXT, + source VARCHAR(16) NOT NULL, + image_path VARCHAR(255), + ocr_raw_text TEXT, + ocr_confidence NUMERIC(6,4), + ocr_flags JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE + )', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at ON ' . $table . ' (project_key, owner_sub, measured_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(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount DECIMAL(20,8) NULL, + total_value_currency VARCHAR(10) NULL, + wallet_balance DECIMAL(28,10) NULL, + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSON NULL, + note TEXT NULL, + source ENUM(\'manual\', \'image_ocr\', \'seed_import\') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) + )', + ]; + + $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Wallet-Snapshots fehlgeschlagen.'); + } + public function ensureMinerTables(): void { if (!$this->tableExists($this->prefix . 'miner_offers')) { @@ -1321,6 +1450,7 @@ final class SchemaManager $this->prefix . 'fx_rates', $this->prefix . 'measurement_rates', $this->prefix . 'payouts', + $this->prefix . 'wallet_snapshots', $this->prefix . 'miner_offers', $this->prefix . 'purchased_miners', ]; @@ -1340,6 +1470,7 @@ final class SchemaManager $this->prefix . 'measurements', $this->prefix . 'measurement_rates', $this->prefix . 'payouts', + $this->prefix . 'wallet_snapshots', $this->prefix . 'miner_offers', $this->prefix . 'targets', $this->prefix . 'dashboard_definitions',