Miner-Upgrade
This commit is contained in:
@@ -344,6 +344,7 @@
|
|||||||
purchased_miners: [],
|
purchased_miners: [],
|
||||||
measurement_rates: [],
|
measurement_rates: [],
|
||||||
},
|
},
|
||||||
|
wallet_snapshots: Array.isArray(normalized.wallet_snapshots) ? normalized.wallet_snapshots : [],
|
||||||
measurements: Array.isArray(normalized.measurements) ? normalized.measurements : [],
|
measurements: Array.isArray(normalized.measurements) ? normalized.measurements : [],
|
||||||
targets: Array.isArray(normalized.targets) ? normalized.targets : [],
|
targets: Array.isArray(normalized.targets) ? normalized.targets : [],
|
||||||
dashboards: Array.isArray(normalized.dashboards) ? normalized.dashboards : [],
|
dashboards: Array.isArray(normalized.dashboards) ? normalized.dashboards : [],
|
||||||
@@ -352,7 +353,15 @@
|
|||||||
latest_measurement: null,
|
latest_measurement: null,
|
||||||
baseline: normalized.settings || null,
|
baseline: normalized.settings || null,
|
||||||
targets: Array.isArray(normalized.targets) ? normalized.targets : [],
|
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,
|
current_hashrate_mh: null,
|
||||||
miner_offers: [],
|
miner_offers: [],
|
||||||
},
|
},
|
||||||
@@ -380,6 +389,7 @@
|
|||||||
: {};
|
: {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
kind: normalized.kind === 'wallet' ? 'wallet' : 'measurement',
|
||||||
suggested: {
|
suggested: {
|
||||||
measured_at: suggested.measured_at || '',
|
measured_at: suggested.measured_at || '',
|
||||||
coins_total: suggested.coins_total ?? '',
|
coins_total: suggested.coins_total ?? '',
|
||||||
@@ -388,6 +398,29 @@
|
|||||||
note: suggested.note || '',
|
note: suggested.note || '',
|
||||||
source: suggested.source || 'image_ocr',
|
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,
|
confidence: typeof normalized.confidence === 'number' ? normalized.confidence : 0,
|
||||||
flags: Array.isArray(normalized.flags) ? normalized.flags : [],
|
flags: Array.isArray(normalized.flags) ? normalized.flags : [],
|
||||||
image_path: normalized.image_path || '',
|
image_path: normalized.image_path || '',
|
||||||
@@ -790,6 +823,7 @@
|
|||||||
];
|
];
|
||||||
const currentCostPlans = Array.isArray(currentSettings.cost_plans) ? currentSettings.cost_plans : [];
|
const currentCostPlans = Array.isArray(currentSettings.cost_plans) ? currentSettings.cost_plans : [];
|
||||||
const currentPayouts = Array.isArray(currentSettings.payouts) ? currentSettings.payouts : [];
|
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 currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : [];
|
||||||
const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : [];
|
const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : [];
|
||||||
const renewableOfferIds = new Set(
|
const renewableOfferIds = new Set(
|
||||||
@@ -925,13 +959,13 @@
|
|||||||
setPurchaseMinerForm((current) => ({
|
setPurchaseMinerForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
offer_id: current.offer_id || String(selectedOffer.id),
|
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) : ''),
|
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_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 || '',
|
reference_price_currency: current.reference_price_currency || selectedOffer.reference_price_currency || '',
|
||||||
auto_renew: current.auto_renew || !!selectedOffer.auto_renew,
|
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) {
|
function measurementFxRate(measurementId, fromCurrency, toCurrency) {
|
||||||
const from = String(fromCurrency || '').toUpperCase();
|
const from = String(fromCurrency || '').toUpperCase();
|
||||||
@@ -1167,6 +1201,7 @@
|
|||||||
}, [payload, projectKey]);
|
}, [payload, projectKey]);
|
||||||
|
|
||||||
const overviewCharts = useMemo(() => {
|
const overviewCharts = useMemo(() => {
|
||||||
|
const chartCoinCurrency = String(currentSettings.crypto_currency || 'DOGE').toUpperCase();
|
||||||
const comparisonRows = measurements.filter((row) => {
|
const comparisonRows = measurements.filter((row) => {
|
||||||
const miningRate = Number(row.doge_per_hour_per_mh_interval);
|
const miningRate = Number(row.doge_per_hour_per_mh_interval);
|
||||||
const price = Number(row.effective_price_per_coin ?? row.price_per_coin);
|
const price = Number(row.effective_price_per_coin ?? row.price_per_coin);
|
||||||
@@ -1185,7 +1220,7 @@
|
|||||||
miningVsPrice: baseMining && basePrice ? [
|
miningVsPrice: baseMining && basePrice ? [
|
||||||
{
|
{
|
||||||
key: 'mining-rate',
|
key: 'mining-rate',
|
||||||
label: 'Mining/h je MH/s Index',
|
label: `${chartCoinCurrency}/h je MH/s Index`,
|
||||||
color: '#2dd4bf',
|
color: '#2dd4bf',
|
||||||
data: comparisonRows.map((row) => ({
|
data: comparisonRows.map((row) => ({
|
||||||
x: fmtDate(row.measured_at),
|
x: fmtDate(row.measured_at),
|
||||||
@@ -1194,7 +1229,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'doge-price',
|
key: 'doge-price',
|
||||||
label: 'DOGE-Kurs Index',
|
label: `${chartCoinCurrency}-Kurs Index`,
|
||||||
color: '#f59e0b',
|
color: '#f59e0b',
|
||||||
data: comparisonRows.map((row) => ({
|
data: comparisonRows.map((row) => ({
|
||||||
x: fmtDate(row.measured_at),
|
x: fmtDate(row.measured_at),
|
||||||
@@ -1203,7 +1238,7 @@
|
|||||||
},
|
},
|
||||||
] : [],
|
] : [],
|
||||||
};
|
};
|
||||||
}, [measurements]);
|
}, [currentSettings.crypto_currency, measurements]);
|
||||||
|
|
||||||
async function submitMeasurement(fromPreview) {
|
async function submitMeasurement(fromPreview) {
|
||||||
const preview = normalizeOcrPreview(ocrPreview);
|
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) {
|
async function deleteMeasurement(id) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
@@ -1320,6 +1384,7 @@
|
|||||||
body.append('image', nextForm.image);
|
body.append('image', nextForm.image);
|
||||||
body.append('date_context', nextForm.date_context);
|
body.append('date_context', nextForm.date_context);
|
||||||
body.append('ocr_hint_text', nextForm.ocr_hint_text);
|
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`, {
|
const data = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/ocr-preview`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
@@ -1534,7 +1599,7 @@
|
|||||||
body: JSON.stringify(payoutForm),
|
body: JSON.stringify(payoutForm),
|
||||||
});
|
});
|
||||||
setMessage('Auszahlung gespeichert.');
|
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);
|
setPayoutModalOpen(false);
|
||||||
await loadBootstrap(projectKey);
|
await loadBootstrap(projectKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1792,6 +1857,75 @@
|
|||||||
setReportCurrencyOverride('');
|
setReportCurrencyOverride('');
|
||||||
setCookie('mining_checker_report_currency', '', 0);
|
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', {
|
return h('div', {
|
||||||
className: 'mc-grid-bg',
|
className: 'mc-grid-bg',
|
||||||
}, [
|
}, [
|
||||||
@@ -1804,6 +1938,9 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function renderTab() {
|
function renderTab() {
|
||||||
|
const currentCoinCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase();
|
||||||
|
const perDayLabel = `${currentCoinCurrency} pro Tag`;
|
||||||
|
|
||||||
if (activeTab === 'overview') {
|
if (activeTab === 'overview') {
|
||||||
const latestValue = latest ? convertMeasurementMoney(latest, latest.current_value, reportCurrency) : null;
|
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
|
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
|
const breakEvenDaysOverall = latest && latest.break_even_days_overall !== null && latest.break_even_days_overall !== undefined
|
||||||
? Number(latest.break_even_days_overall)
|
? Number(latest.break_even_days_overall)
|
||||||
: null;
|
: 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 breakEvenReached = breakEvenRemainingAmount !== null && breakEvenRemainingAmount <= 0;
|
||||||
const breakEvenDate = (() => {
|
const breakEvenEta = latest && latest.break_even_eta_at ? fmtDate(latest.break_even_eta_at) : null;
|
||||||
if (!latest || breakEvenDaysOverall === null || !Number.isFinite(breakEvenDaysOverall)) {
|
const walletBalanceCurrentAsset = payload?.summary?.payouts?.wallet_balance_current_asset;
|
||||||
return null;
|
const holdingsCurrentAsset = payload?.summary?.payouts?.holdings_current_asset;
|
||||||
}
|
|
||||||
const baseTimestamp = Date.parse(String(latest.measured_at || ''));
|
|
||||||
if (!Number.isFinite(baseTimestamp)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return new Date(baseTimestamp + (breakEvenDaysOverall * 24 * 60 * 60 * 1000));
|
|
||||||
})();
|
|
||||||
|
|
||||||
return h('div', { className: 'mc-stack' }, [
|
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.', [
|
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('div', { key: 'stats', className: 'mc-stats-grid' }, [
|
||||||
h(StatCard, {
|
h(StatCard, {
|
||||||
key: 'coins',
|
key: 'coins',
|
||||||
label: 'Coins sichtbar',
|
label: `${currentCoinCurrency} im Miner`,
|
||||||
value: latest ? fmtNumber(latest.coins_total_visible || latest.coins_total, 6) : 'n/a',
|
value: latest ? fmtNumber(latest.coins_total_visible || latest.coins_total, 6) : 'n/a',
|
||||||
sub: latest ? `Stand ${fmtDate(latest.measured_at)}` : '',
|
sub: latest ? `Stand ${fmtDate(latest.measured_at)}` : '',
|
||||||
}),
|
}),
|
||||||
h(StatCard, {
|
h(StatCard, {
|
||||||
key: 'coins-effective',
|
key: 'holdings',
|
||||||
label: 'Coins effektiv',
|
label: `Theoretischer Bestand ${currentCoinCurrency}`,
|
||||||
value: payload?.summary?.payouts ? fmtNumber(payload.summary.payouts.current_effective_coins, 6) : 'n/a',
|
value: payload?.summary?.payouts ? fmtNumber(holdingsCurrentAsset, 6) : 'n/a',
|
||||||
sub: payload?.summary?.payouts ? `Ausgezahlt ${fmtNumber(payload.summary.payouts.total_coins, 6)} DOGE` : '',
|
sub: payload?.summary?.payouts
|
||||||
|
? `Wallet ${fmtNumber(walletBalanceCurrentAsset, 6)} ${currentCoinCurrency} · Miner ${fmtNumber(latest?.coins_total_visible || latest?.coins_total, 6)} ${currentCoinCurrency}`
|
||||||
|
: '',
|
||||||
}),
|
}),
|
||||||
h(StatCard, {
|
h(StatCard, {
|
||||||
key: 'perday',
|
key: 'perday',
|
||||||
label: 'DOGE pro Tag',
|
label: perDayLabel,
|
||||||
value: latest ? fmtNumber(latest.doge_per_day_interval, 4) : 'n/a',
|
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}` : ''),
|
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',
|
label: 'Theoretischer Tagesgewinn',
|
||||||
value: dailyProfit !== null ? fmtMoney(dailyProfit, reportCurrency) : 'n/a',
|
value: dailyProfit !== null ? fmtMoney(dailyProfit, reportCurrency) : 'n/a',
|
||||||
sub: dailyCost !== null
|
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',
|
: 'Kein aktiver Miner fuer diese Waehrung',
|
||||||
}),
|
}),
|
||||||
h(StatCard, {
|
h(StatCard, {
|
||||||
key: 'break-even-point',
|
key: 'break-even-point',
|
||||||
label: 'Break-even',
|
label: 'Cash-Break-even',
|
||||||
value: breakEvenDaysOverall !== null
|
value: breakEvenReached
|
||||||
|
? 'Erreicht'
|
||||||
|
: breakEvenDaysOverall !== null
|
||||||
? `${fmtNumber(breakEvenDaysOverall, 2)} Tage`
|
? `${fmtNumber(breakEvenDaysOverall, 2)} Tage`
|
||||||
: (breakEvenReached ? 'Erreicht' : (investedCapital === null ? 'Keine Mietbasis' : 'n/a')),
|
: (investedCapital === null ? 'Keine Mietbasis' : 'Nicht erreichbar'),
|
||||||
sub: investedCapital !== null
|
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
|
: (breakEvenPrice !== null
|
||||||
? `Break-even-Kurs ${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}`
|
? `Break-even-Kurs ${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}`
|
||||||
: (investedCapital === null
|
: (investedCapital === null
|
||||||
@@ -1904,14 +2041,14 @@
|
|||||||
]),
|
]),
|
||||||
h('div', { key: 'charts', className: 'mc-overview-grid' }, [
|
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('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('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',
|
type: 'line',
|
||||||
series: overviewCharts.miningVsPrice,
|
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' },
|
h('div', { className: 'mc-target-grid' },
|
||||||
currentTargets.map((target, index) => h('div', {
|
currentTargets.map((target, index) => h('div', {
|
||||||
key: index,
|
key: index,
|
||||||
@@ -1924,8 +2061,8 @@
|
|||||||
h('div', { key: 'body', className: 'mc-text mc-target-grid' }, [
|
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: '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: '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: 'doge' }, `Benoetigte ${currentCoinCurrency}: ${fmtNumber(target.required_doge, 6)}`),
|
||||||
h('div', { key: 'remaining' }, `Rest-DOGE: ${fmtNumber(target.remaining_doge, 6)}`),
|
h('div', { key: 'remaining' }, `Rest-${currentCoinCurrency}: ${fmtNumber(target.remaining_doge, 6)}`),
|
||||||
h('div', { key: 'days' }, `Resttage: ${fmtNumber(target.remaining_days, 4)}`),
|
h('div', { key: 'days' }, `Resttage: ${fmtNumber(target.remaining_days, 4)}`),
|
||||||
]),
|
]),
|
||||||
]))
|
]))
|
||||||
@@ -1937,56 +2074,7 @@
|
|||||||
if (activeTab === 'measurements') {
|
if (activeTab === 'measurements') {
|
||||||
return h('div', { className: 'mc-main-grid' }, [
|
return h('div', { className: 'mc-main-grid' }, [
|
||||||
h('div', { className: 'mc-stack' }, [
|
h('div', { className: 'mc-stack' }, [
|
||||||
(function () {
|
renderSharedOcrPanel(),
|
||||||
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.'),
|
|
||||||
]);
|
|
||||||
})(),
|
|
||||||
panel('Messpunkt manuell erfassen', 'Direkte Eingabe eines einzelnen Messpunkts mit serverseitiger Validierung.', h('form', {
|
panel('Messpunkt manuell erfassen', 'Direkte Eingabe eines einzelnen Messpunkts mit serverseitiger Validierung.', h('form', {
|
||||||
className: 'mc-form',
|
className: 'mc-form',
|
||||||
onSubmit: function (event) {
|
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' }, [
|
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('table', { key: 'table', className: 'mc-table' }, [
|
||||||
h('thead', { key: 'thead' }, h('tr', null, [
|
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)))),
|
].map((label) => h('th', { key: label }, label)))),
|
||||||
h('tbody', { key: 'tbody' },
|
h('tbody', { key: 'tbody' },
|
||||||
measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [
|
measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [
|
||||||
h('td', { key: 'measured' }, fmtDate(row.measured_at)),
|
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: '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: 'source' }, row.source),
|
||||||
h('td', { key: 'rate' }, fmtNumber(row.doge_per_day_interval, 4)),
|
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') {
|
if (activeTab === 'dashboards') {
|
||||||
return h('div', { className: 'mc-main-grid' }, [
|
return h('div', { className: 'mc-main-grid' }, [
|
||||||
panel('Dashboard-Builder V1', 'Chart-Typ, X/Y-Feld, Aggregation und einfache Filter werden gespeichert.', h('form', {
|
panel('Dashboard-Builder V1', 'Chart-Typ, X/Y-Feld, Aggregation und einfache Filter werden gespeichert.', h('form', {
|
||||||
@@ -2194,7 +2309,15 @@
|
|||||||
key: 'add-payout',
|
key: 'add-payout',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
className: 'mc-button mc-button--secondary',
|
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'),
|
}, 'Auszahlung erfassen'),
|
||||||
]),
|
]),
|
||||||
h('div', { key: 'payout-list', className: 'mc-table-shell' }, [
|
h('div', { key: 'payout-list', className: 'mc-table-shell' }, [
|
||||||
@@ -2254,7 +2377,7 @@
|
|||||||
])
|
])
|
||||||
: null,
|
: 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: 'break' }, offer.break_even_days !== null ? `${fmtNumber(offer.break_even_days, 2)} Tage` : 'n/a'),
|
||||||
h('td', { key: 'rec' }, [
|
h('td', { key: 'rec' }, [
|
||||||
h('div', { key: 'rec-main' }, offer.recommendation),
|
h('div', { key: 'rec-main' }, offer.recommendation),
|
||||||
@@ -2326,11 +2449,11 @@
|
|||||||
}),
|
}),
|
||||||
h(StatCard, {
|
h(StatCard, {
|
||||||
key: 'scenario-doge',
|
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',
|
value: selectedMinerScenario.scenario_doge_per_day !== null ? fmtNumber(selectedMinerScenario.scenario_doge_per_day, 4) : 'n/a',
|
||||||
sub: selectedMinerScenario.scenario_current_doge_per_day !== null
|
sub: selectedMinerScenario.scenario_current_doge_per_day !== null
|
||||||
? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_doge_per_day, 4)}`
|
? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_doge_per_day, 4)}`
|
||||||
: 'Keine aktuelle DOGE/Tag-Basis',
|
: `Keine aktuelle ${currentCoinCurrency}/Tag-Basis`,
|
||||||
}),
|
}),
|
||||||
h(StatCard, {
|
h(StatCard, {
|
||||||
key: 'scenario-break-even',
|
key: 'scenario-break-even',
|
||||||
@@ -2445,7 +2568,7 @@
|
|||||||
h('form', { key: 'form', className: 'mc-form', onSubmit: submitPayout }, [
|
h('form', { key: 'form', className: 'mc-form', onSubmit: submitPayout }, [
|
||||||
inputField('Auszahlungszeitpunkt', 'datetime-local', payoutForm.payout_at, (value) => setPayoutForm({ ...payoutForm, payout_at: value })),
|
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'),
|
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 })),
|
textareaField('Notiz', payoutForm.note, (value) => setPayoutForm({ ...payoutForm, note: value })),
|
||||||
h('div', { className: 'mc-inline-row' }, [
|
h('div', { className: 'mc-inline-row' }, [
|
||||||
h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPayoutModalOpen(false) }, 'Abbrechen'),
|
h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPayoutModalOpen(false) }, 'Abbrechen'),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"sections": [
|
"sections": [
|
||||||
{ "key": "overview", "label": "Ueberblick" },
|
{ "key": "overview", "label": "Ueberblick" },
|
||||||
{ "key": "measurements", "label": "Messpunkte" },
|
{ "key": "measurements", "label": "Messpunkte" },
|
||||||
|
{ "key": "wallet", "label": "Wallet" },
|
||||||
{ "key": "mining", "label": "Mining" },
|
{ "key": "mining", "label": "Mining" },
|
||||||
{ "key": "dashboards", "label": "Dashboards" },
|
{ "key": "dashboards", "label": "Dashboards" },
|
||||||
{ "key": "settings", "label": "Settings" }
|
{ "key": "settings", "label": "Settings" }
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
|||||||
project_key VARCHAR(64) NOT NULL,
|
project_key VARCHAR(64) NOT NULL,
|
||||||
measured_at DATETIME NOT NULL,
|
measured_at DATETIME NOT NULL,
|
||||||
coins_total DECIMAL(20,6) 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_per_coin DECIMAL(20,8) NULL,
|
||||||
price_currency VARCHAR(10) NULL,
|
price_currency VARCHAR(10) NULL,
|
||||||
note TEXT 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)
|
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 (
|
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
project_key VARCHAR(64) NOT NULL,
|
project_key VARCHAR(64) NOT NULL,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
|||||||
project_key VARCHAR(64) NOT NULL,
|
project_key VARCHAR(64) NOT NULL,
|
||||||
measured_at DATETIME NOT NULL,
|
measured_at DATETIME NOT NULL,
|
||||||
coins_total DECIMAL(20,6) 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_per_coin DECIMAL(20,8) NULL,
|
||||||
price_currency VARCHAR(10) NULL,
|
price_currency VARCHAR(10) NULL,
|
||||||
fx_fetch_id BIGINT UNSIGNED 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)
|
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 (
|
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
project_key VARCHAR(64) NOT NULL,
|
project_key VARCHAR(64) NOT NULL,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
|||||||
owner_sub VARCHAR(128) NOT NULL,
|
owner_sub VARCHAR(128) NOT NULL,
|
||||||
measured_at TIMESTAMP NOT NULL,
|
measured_at TIMESTAMP NOT NULL,
|
||||||
coins_total NUMERIC(20,6) NOT NULL,
|
coins_total NUMERIC(20,6) NOT NULL,
|
||||||
|
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||||
price_per_coin NUMERIC(20,8),
|
price_per_coin NUMERIC(20,8),
|
||||||
price_currency VARCHAR(10),
|
price_currency VARCHAR(10),
|
||||||
fx_fetch_id BIGINT,
|
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
|
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at
|
||||||
ON miningcheck_payouts(project_key, owner_sub, 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 (
|
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
project_key VARCHAR(64) NOT NULL,
|
project_key VARCHAR(64) NOT NULL,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
|||||||
project_key VARCHAR(64) NOT NULL,
|
project_key VARCHAR(64) NOT NULL,
|
||||||
measured_at DATETIME NOT NULL,
|
measured_at DATETIME NOT NULL,
|
||||||
coins_total DECIMAL(20,6) 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_per_coin DECIMAL(20,8) NULL,
|
||||||
price_currency VARCHAR(10) NULL,
|
price_currency VARCHAR(10) NULL,
|
||||||
fx_fetch_id BIGINT UNSIGNED 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)
|
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 (
|
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
project_key VARCHAR(64) NOT NULL,
|
project_key VARCHAR(64) NOT NULL,
|
||||||
|
|||||||
@@ -253,6 +253,15 @@ final class Router
|
|||||||
Http::json(['data' => $this->savePayout($projectKey, Http::input())], 201);
|
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') {
|
if ($resource === 'miner-offers' && $method === 'GET') {
|
||||||
Http::json(['data' => $this->minerOffers($projectKey)]);
|
Http::json(['data' => $this->minerOffers($projectKey)]);
|
||||||
}
|
}
|
||||||
@@ -329,6 +338,10 @@ final class Router
|
|||||||
'purchased_miners' => [],
|
'purchased_miners' => [],
|
||||||
'measurement_rates' => [],
|
'measurement_rates' => [],
|
||||||
], ['project_key' => $projectKey]);
|
], ['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), [], [
|
$measurements = $this->safeTimed('bootstrap.measurements', fn () => $this->bootstrapMeasurements($projectKey, $settings, $view), [], [
|
||||||
'project_key' => $projectKey,
|
'project_key' => $projectKey,
|
||||||
'view' => $view,
|
'view' => $view,
|
||||||
@@ -369,6 +382,7 @@ final class Router
|
|||||||
return [
|
return [
|
||||||
'project' => $this->repository()->getProject($projectKey),
|
'project' => $this->repository()->getProject($projectKey),
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
|
'wallet_snapshots' => $walletSnapshots,
|
||||||
'measurements' => $measurements,
|
'measurements' => $measurements,
|
||||||
'targets' => $targets,
|
'targets' => $targets,
|
||||||
'dashboards' => $dashboards,
|
'dashboards' => $dashboards,
|
||||||
@@ -483,6 +497,7 @@ final class Router
|
|||||||
'measurements' => $this->safeRead(fn () => $this->repository()->listAllMeasurements($projectKey), []),
|
'measurements' => $this->safeRead(fn () => $this->repository()->listAllMeasurements($projectKey), []),
|
||||||
'measurement_rates' => $this->safeRead(fn () => $this->repository()->listMeasurementRates($projectKey), []),
|
'measurement_rates' => $this->safeRead(fn () => $this->repository()->listMeasurementRates($projectKey), []),
|
||||||
'payouts' => $this->safeRead(fn () => $this->repository()->listPayouts($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), []),
|
'targets' => $this->safeRead(fn () => $this->repository()->listTargets($projectKey), []),
|
||||||
'dashboards' => $this->safeRead(fn () => $this->repository()->listDashboards($projectKey), []),
|
'dashboards' => $this->safeRead(fn () => $this->repository()->listDashboards($projectKey), []),
|
||||||
'miner_offers' => $this->safeRead(fn () => $this->repository()->listMinerOffers($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 = [];
|
$minerOfferIdMap = [];
|
||||||
foreach ($backup['miner_offers'] as $offer) {
|
foreach ($backup['miner_offers'] as $offer) {
|
||||||
$savedOffer = $this->repository()->saveMinerOffer($projectKey, [
|
$savedOffer = $this->repository()->saveMinerOffer($projectKey, [
|
||||||
@@ -694,6 +726,7 @@ final class Router
|
|||||||
'purchased_miners' => count($backup['purchased_miners']),
|
'purchased_miners' => count($backup['purchased_miners']),
|
||||||
'cost_plans' => count($backup['cost_plans']),
|
'cost_plans' => count($backup['cost_plans']),
|
||||||
'payouts' => count($backup['payouts']),
|
'payouts' => count($backup['payouts']),
|
||||||
|
'wallet_snapshots' => count($backup['wallet_snapshots']),
|
||||||
'targets' => count($backup['targets']),
|
'targets' => count($backup['targets']),
|
||||||
'dashboards' => count($backup['dashboards']),
|
'dashboards' => count($backup['dashboards']),
|
||||||
'miner_offers' => count($backup['miner_offers']),
|
'miner_offers' => count($backup['miner_offers']),
|
||||||
@@ -1191,7 +1224,7 @@ final class Router
|
|||||||
|
|
||||||
private function bootstrapMeasurements(string $projectKey, array $settings, string $view): array
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,6 +1245,15 @@ final class Router
|
|||||||
return $this->analytics()->enrichMeasurements($rows, $settings);
|
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
|
private function bootstrapTargets(string $projectKey, string $view): array
|
||||||
{
|
{
|
||||||
return in_array($view, ['overview', 'mining'], true) ? $this->targets($projectKey) : [];
|
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->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $projectTimezone)
|
||||||
: $this->currentTimestamp(),
|
: $this->currentTimestamp(),
|
||||||
'coins_total' => $this->requiredDecimal($input['coins_total'] ?? null, 'coins_total'),
|
'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_per_coin' => $this->optionalDecimal($input['price_per_coin'] ?? null),
|
||||||
'price_currency' => $this->optionalCurrency($input['price_currency'] ?? null),
|
'price_currency' => $this->optionalCurrency($input['price_currency'] ?? null),
|
||||||
'note' => $this->optionalString($input['note'] ?? null, 2000),
|
'note' => $this->optionalString($input['note'] ?? null, 2000),
|
||||||
@@ -1330,7 +1373,7 @@ final class Router
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$payload = $this->parseImportLine($trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey));
|
$payload = $this->parseImportLine($projectKey, $trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey));
|
||||||
$this->syncCurrencyCatalogForMeasurement($payload);
|
$this->syncCurrencyCatalogForMeasurement($payload);
|
||||||
$payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, false);
|
$payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, false);
|
||||||
$result = $this->repository()->createMeasurementIfNotExists($projectKey, $payload);
|
$result = $this->repository()->createMeasurementIfNotExists($projectKey, $payload);
|
||||||
@@ -1368,6 +1411,15 @@ final class Router
|
|||||||
|
|
||||||
private function syncCurrencyCatalogForMeasurement(array $payload): void
|
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'] ?? '')));
|
$priceCurrency = strtoupper(trim((string) ($payload['price_currency'] ?? '')));
|
||||||
if ($priceCurrency === '') {
|
if ($priceCurrency === '') {
|
||||||
return;
|
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));
|
$parts = array_map('trim', explode('|', $line));
|
||||||
if (count($parts) < 2) {
|
if (count($parts) < 2) {
|
||||||
@@ -1402,6 +1454,7 @@ final class Router
|
|||||||
return [
|
return [
|
||||||
'measured_at' => $measuredAt,
|
'measured_at' => $measuredAt,
|
||||||
'coins_total' => $coinsTotal,
|
'coins_total' => $coinsTotal,
|
||||||
|
'coin_currency' => $this->measurementCoinCurrency($projectKey),
|
||||||
'price_per_coin' => $pricePerCoin,
|
'price_per_coin' => $pricePerCoin,
|
||||||
'price_currency' => $priceCurrency,
|
'price_currency' => $priceCurrency,
|
||||||
'note' => $note,
|
'note' => $note,
|
||||||
@@ -1629,6 +1682,36 @@ final class Router
|
|||||||
return $this->repository()->savePayout($projectKey, $payload);
|
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
|
private function saveMinerOffer(string $projectKey, array $input): array
|
||||||
{
|
{
|
||||||
$payload = [
|
$payload = [
|
||||||
@@ -1658,9 +1741,16 @@ final class Router
|
|||||||
throw new ApiException('Miner-Angebot nicht gefunden.', 404);
|
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'] ?? '');
|
$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);
|
$purchaseCost = $this->optionalDecimal($input['total_cost_amount'] ?? null);
|
||||||
if ($purchaseCost === null) {
|
if ($purchaseCost === null || !$isAutoRenew) {
|
||||||
$purchaseCost = $this->resolveOfferPurchaseCost(array_merge($offer, [
|
$purchaseCost = $this->resolveOfferPurchaseCost(array_merge($offer, [
|
||||||
'price_currency' => $purchaseCurrency !== '' ? $purchaseCurrency : ($offer['price_currency'] ?? ''),
|
'price_currency' => $purchaseCurrency !== '' ? $purchaseCurrency : ($offer['price_currency'] ?? ''),
|
||||||
]));
|
]));
|
||||||
@@ -1684,7 +1774,7 @@ final class Router
|
|||||||
'usd_reference_amount' => $offer['usd_reference_amount'] ?? null,
|
'usd_reference_amount' => $offer['usd_reference_amount'] ?? null,
|
||||||
'reference_price_amount' => $referencePriceAmount,
|
'reference_price_amount' => $referencePriceAmount,
|
||||||
'reference_price_currency' => $referencePriceCurrency,
|
'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),
|
'note' => $this->optionalString($input['note'] ?? ($offer['note'] ?? null), 1000),
|
||||||
'is_active' => 1,
|
'is_active' => 1,
|
||||||
]);
|
]);
|
||||||
@@ -2171,6 +2261,17 @@ final class Router
|
|||||||
return null;
|
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
|
private function ensureMeasurementFxReferences(string $projectKey, array $rows, ?array $settings = null): array
|
||||||
{
|
{
|
||||||
$maxAgeHours = self::FX_FETCH_MAX_AGE_HOURS;
|
$maxAgeHours = self::FX_FETCH_MAX_AGE_HOURS;
|
||||||
|
|||||||
@@ -34,23 +34,26 @@ final class AnalyticsService
|
|||||||
$previousIntervalRate = null;
|
$previousIntervalRate = null;
|
||||||
$result = [];
|
$result = [];
|
||||||
$payoutIndex = 0;
|
$payoutIndex = 0;
|
||||||
$cumulativePayouts = 0.0;
|
|
||||||
$lastPayoutTs = null;
|
$lastPayoutTs = null;
|
||||||
|
$payoutsByAsset = [];
|
||||||
$latestPriceByCurrency = [];
|
$latestPriceByCurrency = [];
|
||||||
|
|
||||||
foreach ($measurements as $row) {
|
foreach ($measurements as $row) {
|
||||||
$measuredTs = $this->utcTimestamp((string) $row['measured_at']);
|
$measuredTs = $this->utcTimestamp((string) $row['measured_at']);
|
||||||
|
$coinCurrency = strtoupper(trim((string) ($row['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')));
|
||||||
while (isset($payouts[$payoutIndex])) {
|
while (isset($payouts[$payoutIndex])) {
|
||||||
$payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? ''));
|
$payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? ''));
|
||||||
if ($payoutTs <= 0 || $payoutTs > $measuredTs) {
|
if ($payoutTs <= 0 || $payoutTs > $measuredTs) {
|
||||||
break;
|
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;
|
$lastPayoutTs = $payoutTs;
|
||||||
$payoutIndex++;
|
$payoutIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cumulativePayouts = (float) ($payoutsByAsset[$coinCurrency] ?? 0.0);
|
||||||
$visibleCoinsTotal = (float) $row['coins_total'];
|
$visibleCoinsTotal = (float) $row['coins_total'];
|
||||||
$effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts;
|
$effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts;
|
||||||
$growth = $effectiveCoinsTotal - $baselineCoins;
|
$growth = $effectiveCoinsTotal - $baselineCoins;
|
||||||
@@ -120,7 +123,7 @@ final class AnalyticsService
|
|||||||
}
|
}
|
||||||
if ($price === null) {
|
if ($price === null) {
|
||||||
foreach (['USD', 'EUR'] as $fallbackCurrency) {
|
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) {
|
if ($fxPrice !== null && $fxPrice > 0) {
|
||||||
$latestPriceByCurrency[$fallbackCurrency] = $fxPrice;
|
$latestPriceByCurrency[$fallbackCurrency] = $fxPrice;
|
||||||
}
|
}
|
||||||
@@ -159,6 +162,7 @@ final class AnalyticsService
|
|||||||
$result[] = array_merge($row, [
|
$result[] = array_merge($row, [
|
||||||
'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6),
|
'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6),
|
||||||
'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6),
|
'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6),
|
||||||
|
'coin_currency' => $coinCurrency,
|
||||||
'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6),
|
'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6),
|
||||||
'growth_since_baseline' => $this->roundOrNull($growth, 6),
|
'growth_since_baseline' => $this->roundOrNull($growth, 6),
|
||||||
'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4),
|
'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4),
|
||||||
@@ -296,26 +300,60 @@ final class AnalyticsService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '');
|
$latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '');
|
||||||
$investedCapital = $latestCurrency !== ''
|
$latestMeasuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? ''));
|
||||||
|
$cashInvestedCapital = $latestCurrency !== ''
|
||||||
? $this->totalInvestmentBasis(
|
? $this->totalInvestmentBasis(
|
||||||
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||||
$purchasedMiners,
|
$purchasedMiners,
|
||||||
$this->utcTimestamp((string) ($latest['measured_at'] ?? '')),
|
$latestMeasuredTs,
|
||||||
$latestCurrency,
|
$latestCurrency,
|
||||||
$latest
|
$latest,
|
||||||
|
'cash'
|
||||||
)
|
)
|
||||||
: null;
|
: 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;
|
$currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null;
|
||||||
$breakEvenRemainingAmount = $investedCapital;
|
$breakEvenRemainingAmount = ($cashInvestedCapital !== null && $totalHoldingsValue !== null)
|
||||||
$breakEvenDaysOverall = (
|
? max(0.0, $cashInvestedCapital - $totalHoldingsValue)
|
||||||
$investedCapital !== null &&
|
: $cashInvestedCapital;
|
||||||
$currentDailyRevenue !== null &&
|
$breakEvenProjection = (
|
||||||
$currentDailyRevenue > 0
|
$cashInvestedCapital !== null &&
|
||||||
) ? ($investedCapital / $currentDailyRevenue) : 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, [
|
$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_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8),
|
||||||
'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4),
|
'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4),
|
||||||
|
'break_even_eta_at' => $breakEvenProjection['eta'] ?? null,
|
||||||
]);
|
]);
|
||||||
$currentProjection = $this->projectPerformance(
|
$currentProjection = $this->projectPerformance(
|
||||||
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
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),
|
'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_visible_coins' => $this->roundOrNull((float) ($latest['coins_total_visible'] ?? $latest['coins_total']), 6),
|
||||||
'current_effective_coins' => $this->roundOrNull((float) ($latest['coins_total_effective'] ?? $latest['coins_total']), 6),
|
'current_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),
|
'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4),
|
||||||
'miner_offers' => $offerSummary,
|
'miner_offers' => $offerSummary,
|
||||||
@@ -628,7 +669,7 @@ final class AnalyticsService
|
|||||||
if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) {
|
if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!$this->entryIsCovered($miner, $measurementTs > 0 ? $measurementTs : null, 'purchased_at')) {
|
if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +691,7 @@ final class AnalyticsService
|
|||||||
return $matched ? $total : null;
|
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));
|
$target = strtoupper(trim($targetCurrency));
|
||||||
if ($target === '') {
|
if ($target === '') {
|
||||||
@@ -661,26 +702,16 @@ final class AnalyticsService
|
|||||||
$matched = false;
|
$matched = false;
|
||||||
|
|
||||||
$purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs, $fxContext);
|
$purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs, $fxContext);
|
||||||
if ($purchasedTotal !== null) {
|
if ($fundingSource === null && $purchasedTotal !== null) {
|
||||||
$matched = true;
|
$matched = true;
|
||||||
$total += $purchasedTotal;
|
$total += $purchasedTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($costPlans as $plan) {
|
foreach ($costPlans as $plan) {
|
||||||
if (empty($plan['is_active'])) {
|
if ($measurementTs > 0 && $this->utcTimestamp((string) ($plan['starts_at'] ?? '')) > $measurementTs) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($fundingSource !== null && $this->entryFundingSource($plan) !== $fundingSource) {
|
||||||
$startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? ''));
|
|
||||||
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
|
|
||||||
if ($startTs <= 0 || $runtimeMonths <= 0 || ($measurementTs > 0 && $measurementTs < $startTs)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$runtimeDays = $runtimeMonths * 30.4375;
|
|
||||||
$endTs = (int) round($startTs + ($runtimeDays * 86400));
|
|
||||||
$isCovered = !empty($plan['auto_renew']) || $measurementTs <= 0 || $measurementTs <= $endTs;
|
|
||||||
if (!$isCovered) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,6 +730,31 @@ final class AnalyticsService
|
|||||||
$total += $converted;
|
$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;
|
return $matched ? $total : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1014,6 +1070,153 @@ final class AnalyticsService
|
|||||||
return $endIndex - $startIndex + 1;
|
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
|
private function utcTimestamp(?string $value): int
|
||||||
{
|
{
|
||||||
$normalized = trim((string) $value);
|
$normalized = trim((string) $value);
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ final class OcrService
|
|||||||
$flags[] = 'ocr_hint_text_used';
|
$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['image_path'] = $targetFile;
|
||||||
$parsed['raw_text'] = $rawText;
|
$parsed['raw_text'] = $rawText;
|
||||||
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
|
$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')) !== '';
|
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 = [];
|
$flags = [];
|
||||||
$suggestedTime = null;
|
$suggestedTime = null;
|
||||||
@@ -433,6 +457,107 @@ final class OcrService
|
|||||||
],
|
],
|
||||||
'confidence' => round($confidence, 4),
|
'confidence' => round($confidence, 4),
|
||||||
'flags' => $flags,
|
'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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ final class MiningRepository
|
|||||||
'owner_sub' => $this->ownerSub,
|
'owner_sub' => $this->ownerSub,
|
||||||
'measured_at' => $payload['measured_at'],
|
'measured_at' => $payload['measured_at'],
|
||||||
'coins_total' => $payload['coins_total'],
|
'coins_total' => $payload['coins_total'],
|
||||||
|
'coin_currency' => $payload['coin_currency'] ?? 'DOGE',
|
||||||
'price_per_coin' => $payload['price_per_coin'],
|
'price_per_coin' => $payload['price_per_coin'],
|
||||||
'price_currency' => $payload['price_currency'],
|
'price_currency' => $payload['price_currency'],
|
||||||
'fx_fetch_id' => $payload['fx_fetch_id'] ?? null,
|
'fx_fetch_id' => $payload['fx_fetch_id'] ?? null,
|
||||||
@@ -243,10 +244,10 @@ final class MiningRepository
|
|||||||
if ($this->driver === 'pgsql') {
|
if ($this->driver === 'pgsql') {
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'INSERT INTO ' . $this->table('measurements') . ' (
|
'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
|
source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||||
) VALUES (
|
) 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)
|
:source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb)
|
||||||
)
|
)
|
||||||
RETURNING *'
|
RETURNING *'
|
||||||
@@ -259,10 +260,10 @@ final class MiningRepository
|
|||||||
|
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'INSERT INTO ' . $this->table('measurements') . ' (
|
'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
|
source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||||
) VALUES (
|
) 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
|
:source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags
|
||||||
)'
|
)'
|
||||||
);
|
);
|
||||||
@@ -441,6 +442,71 @@ final class MiningRepository
|
|||||||
return $this->normalizeRow($fetch->fetch() ?: []);
|
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
|
public function listTargets(string $projectKey): array
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
@@ -1195,6 +1261,7 @@ final class MiningRepository
|
|||||||
'fx_rates' => $this->prefix . 'fx_rates',
|
'fx_rates' => $this->prefix . 'fx_rates',
|
||||||
'measurement_rates' => $this->prefix . 'measurement_rates',
|
'measurement_rates' => $this->prefix . 'measurement_rates',
|
||||||
'payouts' => $this->prefix . 'payouts',
|
'payouts' => $this->prefix . 'payouts',
|
||||||
|
'wallet_snapshots' => $this->prefix . 'wallet_snapshots',
|
||||||
'miner_offers' => $this->prefix . 'miner_offers',
|
'miner_offers' => $this->prefix . 'miner_offers',
|
||||||
'purchased_miners' => $this->prefix . 'purchased_miners',
|
'purchased_miners' => $this->prefix . 'purchased_miners',
|
||||||
default => throw new \RuntimeException('Unknown mining table: ' . $logicalName),
|
default => throw new \RuntimeException('Unknown mining table: ' . $logicalName),
|
||||||
@@ -1273,7 +1340,7 @@ final class MiningRepository
|
|||||||
|
|
||||||
private function normalizeRow(array $row): array
|
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]) !== '') {
|
if (array_key_exists($jsonField, $row) && is_string($row[$jsonField]) && trim($row[$jsonField]) !== '') {
|
||||||
$decoded = json_decode($row[$jsonField], true);
|
$decoded = json_decode($row[$jsonField], true);
|
||||||
if (json_last_error() === JSON_ERROR_NONE) {
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
|||||||
@@ -188,6 +188,10 @@ final class SchemaManager
|
|||||||
$this->ensureMeasurementFxReferenceColumn();
|
$this->ensureMeasurementFxReferenceColumn();
|
||||||
$applied[] = 'measurement_fx_reference';
|
$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')) {
|
if (!$this->tableExists($this->prefix . 'measurement_rates')) {
|
||||||
$this->ensureMeasurementRatesTable();
|
$this->ensureMeasurementRatesTable();
|
||||||
@@ -197,6 +201,10 @@ final class SchemaManager
|
|||||||
$this->ensurePayoutsTable();
|
$this->ensurePayoutsTable();
|
||||||
$applied[] = 'payouts_table';
|
$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')) {
|
if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) {
|
||||||
$this->ensureFxRatesTable();
|
$this->ensureFxRatesTable();
|
||||||
$applied[] = 'fx_rates_table';
|
$applied[] = 'fx_rates_table';
|
||||||
@@ -293,6 +301,10 @@ final class SchemaManager
|
|||||||
$this->ensureMeasurementFxReferenceColumn();
|
$this->ensureMeasurementFxReferenceColumn();
|
||||||
$applied[] = 'measurement_fx_reference';
|
$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')) {
|
if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) {
|
||||||
$this->ensureFxRatesTable();
|
$this->ensureFxRatesTable();
|
||||||
@@ -308,6 +320,10 @@ final class SchemaManager
|
|||||||
$this->ensurePayoutsTable();
|
$this->ensurePayoutsTable();
|
||||||
$applied[] = 'payouts_table';
|
$applied[] = 'payouts_table';
|
||||||
}
|
}
|
||||||
|
if (!$this->tableExists($this->prefix . 'wallet_snapshots')) {
|
||||||
|
$this->ensureWalletSnapshotsTable();
|
||||||
|
$applied[] = 'wallet_snapshots_table';
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->tableExists($this->prefix . 'miner_offers')) {
|
if (!$this->tableExists($this->prefix . 'miner_offers')) {
|
||||||
$this->upgradeMinerOffersTable();
|
$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
|
private function ensureLegacyMinerOfferImportColumns(): void
|
||||||
{
|
{
|
||||||
$table = $this->prefix . 'miner_offers';
|
$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')) {
|
if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) {
|
||||||
$upgrades[] = 'measurement_fx_reference';
|
$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')) {
|
if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) {
|
||||||
$upgrades[] = 'fx_rates_table';
|
$upgrades[] = 'fx_rates_table';
|
||||||
@@ -609,6 +676,9 @@ final class SchemaManager
|
|||||||
if (!$this->tableExists($this->prefix . 'payouts')) {
|
if (!$this->tableExists($this->prefix . 'payouts')) {
|
||||||
$upgrades[] = 'payouts_table';
|
$upgrades[] = 'payouts_table';
|
||||||
}
|
}
|
||||||
|
if (!$this->tableExists($this->prefix . 'wallet_snapshots')) {
|
||||||
|
$upgrades[] = 'wallet_snapshots_table';
|
||||||
|
}
|
||||||
if (!$this->tableExists($this->prefix . 'miner_offers')) {
|
if (!$this->tableExists($this->prefix . 'miner_offers')) {
|
||||||
$upgrades[] = 'miner_offers_table';
|
$upgrades[] = 'miner_offers_table';
|
||||||
}
|
}
|
||||||
@@ -849,6 +919,65 @@ final class SchemaManager
|
|||||||
$this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Auszahlungen fehlgeschlagen.');
|
$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
|
public function ensureMinerTables(): void
|
||||||
{
|
{
|
||||||
if (!$this->tableExists($this->prefix . 'miner_offers')) {
|
if (!$this->tableExists($this->prefix . 'miner_offers')) {
|
||||||
@@ -1321,6 +1450,7 @@ final class SchemaManager
|
|||||||
$this->prefix . 'fx_rates',
|
$this->prefix . 'fx_rates',
|
||||||
$this->prefix . 'measurement_rates',
|
$this->prefix . 'measurement_rates',
|
||||||
$this->prefix . 'payouts',
|
$this->prefix . 'payouts',
|
||||||
|
$this->prefix . 'wallet_snapshots',
|
||||||
$this->prefix . 'miner_offers',
|
$this->prefix . 'miner_offers',
|
||||||
$this->prefix . 'purchased_miners',
|
$this->prefix . 'purchased_miners',
|
||||||
];
|
];
|
||||||
@@ -1340,6 +1470,7 @@ final class SchemaManager
|
|||||||
$this->prefix . 'measurements',
|
$this->prefix . 'measurements',
|
||||||
$this->prefix . 'measurement_rates',
|
$this->prefix . 'measurement_rates',
|
||||||
$this->prefix . 'payouts',
|
$this->prefix . 'payouts',
|
||||||
|
$this->prefix . 'wallet_snapshots',
|
||||||
$this->prefix . 'miner_offers',
|
$this->prefix . 'miner_offers',
|
||||||
$this->prefix . 'targets',
|
$this->prefix . 'targets',
|
||||||
$this->prefix . 'dashboard_definitions',
|
$this->prefix . 'dashboard_definitions',
|
||||||
|
|||||||
Reference in New Issue
Block a user