asdasd
This commit is contained in:
70
custom/apps/README.md
Normal file
70
custom/apps/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Module
|
||||
|
||||
Installierbare und fachliche Apps liegen in diesem Projekt unter `custom/apps/<app>/`.
|
||||
|
||||
Die neue Desktop-Shell ist nur die UI-Schicht. Modul-Businesslogik wird nicht in den Desktop-Core verschoben.
|
||||
|
||||
## Aktuelle Modulstruktur
|
||||
|
||||
Ein Desktop-Modul kann zusaetzlich diese Projektdateien besitzen:
|
||||
|
||||
- `desktop.php` fuer die Desktop-App-Metadaten und Asset-Definitionen
|
||||
- `pages/` fuer Standalone-Seiten oder iframe/native Einstiegspunkte
|
||||
- `api/` fuer modulinterne HTTP-Endpunkte
|
||||
- `assets/` fuer modulnahe CSS- und JS-Dateien
|
||||
- `docs/README.md` fuer modulspezifische Hinweise, API und Sonderregeln
|
||||
- `module.json` fuer Setup, Desktop-Freigabe, Widget-Funktionen und Cron-Endpunkte
|
||||
|
||||
Custom-App-Assets werden nicht direkt aus `public/` geladen, sondern ueber den generischen Endpoint `/module-assets/index.php` aus dem jeweiligen App-Ordner ausgeliefert.
|
||||
|
||||
## Trennung zwischen Systemtools und Modulen
|
||||
|
||||
- `Systemtools` sind globale Verwaltungs- und Setup-Apps und nicht installierbar
|
||||
- aktuelle Beispiele: `User Management`, `User Self Management`, `Cron Tool`
|
||||
- `Custom Apps` sind installierbare Fachanwendungen
|
||||
- aktuelle Beispiele: `Mining-Checker`, `Waehrungs-Checker`, `Boersenchecker`, `Pi-hole`
|
||||
|
||||
Diese Trennung wird ueber App-Metadaten gesteuert:
|
||||
|
||||
- `app_scope`
|
||||
- `core`
|
||||
- `system_tool`
|
||||
- `module`
|
||||
- `installable`
|
||||
- `false` fuer Core- und Systemtools
|
||||
- `true` fuer installierbare Module
|
||||
|
||||
## Modul-Metadaten in `module.json`
|
||||
|
||||
Module sollen ihre Desktop-Faehigkeiten zentral in `module.json` beschreiben.
|
||||
|
||||
Wichtige Bausteine:
|
||||
|
||||
- `desktop`
|
||||
- `available`
|
||||
- `show_on_desktop`
|
||||
- `show_in_start_menu`
|
||||
- `widgets`
|
||||
- beschreibt Widget-Funktionen, die nur fuer installierte Module verfuegbar sind
|
||||
- `cron_jobs`
|
||||
- beschreibt Cron-Endpunkte fuer die zentrale Cron-Verwaltung
|
||||
|
||||
Die Desktop-Shell liest diese Angaben automatisch ein. Neue Modul-Crons und Widgets muessen deshalb nicht zusaetzlich in einer separaten globalen Liste nachgetragen werden, solange sie sauber im Manifest beschrieben sind.
|
||||
|
||||
## Globale Desktop-Standards fuer Module
|
||||
|
||||
- gemeinsame Desktop-Mechaniken wie Fenster, Tray, globale Persistenz und Debug-Infrastruktur liegen im Desktop-Core
|
||||
- Module und andere Apps sollen keine eigene Grund-Debug-Oberflaeche im Stil eines separaten globalen Debuggers bauen
|
||||
- fuer Debugging im Desktop gilt das zentrale Admin-Debug-Widget neben der Uhr mit eigenem Debug-Fenster als Standard
|
||||
- Debug-Events aus Modulen sollen in den gemeinsamen Desktop-Debug-Bus geschrieben werden, damit sie im globalen Debug-Fenster sichtbar sind
|
||||
- wenn das globale Debug-Fenster geschlossen ist, sollen Module kein dauerhaft aktives Live-Debugging erzwingen
|
||||
- app-spezifische Debug-Darstellungen sind nur zulaessig, wenn sie fachliche Zusatzinformationen zeigen, die ueber den globalen Stream hinausgehen
|
||||
|
||||
## Pflegehinweis
|
||||
|
||||
Diese Datei muss gepflegt bleiben, wenn sich Modulstruktur oder Modulregeln aendern.
|
||||
|
||||
Wichtige Inhalte aus dieser Datei muessen ebenfalls zentral gepflegt werden in:
|
||||
|
||||
- [CONTENT.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/docs/CONTENT.md)
|
||||
- [WEITERENTWICKLUNG.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/docs/WEITERENTWICKLUNG.md)
|
||||
149
custom/apps/boersenchecker/api/chart_data.php
Normal file
149
custom/apps/boersenchecker/api/chart_data.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
session_start();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
ModuleHttp::requireDesktopAccess(dirname(__DIR__, 3), true);
|
||||
require_auth();
|
||||
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$instrumentId = (int) ($_GET['instrument_id'] ?? 0);
|
||||
if ($instrumentId <= 0) {
|
||||
echo json_encode(['ok' => false, 'message' => 'instrument_id fehlt.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
module_fn('boersenchecker', 'ensure_schema');
|
||||
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
|
||||
$positionTable = module_fn('boersenchecker', 'table', 'positions');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT i.id, i.name, i.symbol, i.isin, i.quote_currency
|
||||
FROM ' . $instrumentTable . ' i
|
||||
INNER JOIN ' . $positionTable . ' p ON p.instrument_id = i.id
|
||||
WHERE i.id = :id AND p.owner_sub = :owner_sub
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $instrumentId,
|
||||
'owner_sub' => $ownerSub,
|
||||
]);
|
||||
$instrument = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!is_array($instrument)) {
|
||||
echo json_encode(['ok' => false, 'message' => 'Aktie nicht verfuegbar.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$quoteStmt = $pdo->prepare(
|
||||
'SELECT id, price, currency, quoted_at, source, created_at
|
||||
FROM ' . $quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
ORDER BY quoted_at ASC, created_at ASC, id ASC'
|
||||
);
|
||||
$quoteStmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
]);
|
||||
$quotes = $quoteStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
if ($quotes === []) {
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => 'Keine lokalen Kursdaten fuer diese Aktie vorhanden.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$dailyMap = [];
|
||||
foreach ($quotes as $quote) {
|
||||
$localDate = trim((string) module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($quote['quoted_at'] ?? ''),
|
||||
(string) ($quote['source'] ?? ''),
|
||||
'Y-m-d'
|
||||
));
|
||||
$localDateTime = trim((string) module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($quote['quoted_at'] ?? ''),
|
||||
(string) ($quote['source'] ?? ''),
|
||||
'Y-m-d H:i:s'
|
||||
));
|
||||
if ($localDate === '' || !is_numeric($quote['price'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$point = [
|
||||
'date' => $localDate,
|
||||
'close' => (float) $quote['price'],
|
||||
'currency' => strtoupper(trim((string) ($quote['currency'] ?? ''))),
|
||||
'quoted_at' => $localDateTime,
|
||||
'source' => (string) ($quote['source'] ?? ''),
|
||||
];
|
||||
|
||||
if (!isset($dailyMap[$localDate]) || strcmp($localDateTime, (string) ($dailyMap[$localDate]['quoted_at'] ?? '')) >= 0) {
|
||||
$dailyMap[$localDate] = $point;
|
||||
}
|
||||
}
|
||||
|
||||
$daily = array_values($dailyMap);
|
||||
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
|
||||
|
||||
if ($daily === []) {
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => 'Keine gueltigen lokalen Schlusskurse fuer diese Aktie vorhanden.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aggregate = static function (array $points, string $format): array {
|
||||
$result = [];
|
||||
$timezone = new DateTimeZone(nexus_display_timezone_name());
|
||||
foreach ($points as $point) {
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', (string) ($point['date'] ?? ''), $timezone);
|
||||
if (!$date instanceof DateTimeImmutable) {
|
||||
continue;
|
||||
}
|
||||
$bucket = $date->format($format);
|
||||
$result[$bucket] = $point;
|
||||
}
|
||||
|
||||
return array_values($result);
|
||||
};
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'symbol' => strtoupper(trim((string) ($instrument['symbol'] ?? ''))),
|
||||
'isin' => strtoupper(trim((string) ($instrument['isin'] ?? ''))),
|
||||
'instrument_name' => (string) ($instrument['name'] ?? ''),
|
||||
'currency' => strtoupper(trim((string) ($instrument['quote_currency'] ?? ''))),
|
||||
'daily' => $daily,
|
||||
'weekly' => $aggregate($daily, 'o-W'),
|
||||
'monthly' => $aggregate($daily, 'Y-m'),
|
||||
'source' => 'database:quotes',
|
||||
'source_label' => 'Lokale Kurshistorie',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => 'Chartdaten konnten nicht geladen werden.',
|
||||
'error' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
109
custom/apps/boersenchecker/api/index.php
Normal file
109
custom/apps/boersenchecker/api/index.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
|
||||
$prefix = '/api/boersenchecker/index.php/';
|
||||
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
|
||||
|
||||
if ($relativePath === '') {
|
||||
$relativePath = str_starts_with($requestPath, $prefix)
|
||||
? substr($requestPath, strlen($prefix))
|
||||
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim($relativePath, '/'));
|
||||
|
||||
if ($normalized === 'v1/chart-data') {
|
||||
require __DIR__ . '/chart_data.php';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($normalized === 'v1/cron/refresh-quotes' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
try {
|
||||
$result = module_fn('boersenchecker', 'scheduled_refresh_quotes', [
|
||||
'trigger_source' => 'cron',
|
||||
]);
|
||||
http_response_code(!empty($result['ok']) ? 200 : 500);
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $exception) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($normalized === 'v1/widget/refresh-quotes' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
try {
|
||||
$result = module_fn('boersenchecker', 'scheduled_refresh_quotes', [
|
||||
'trigger_source' => 'widget',
|
||||
]);
|
||||
http_response_code(!empty($result['ok']) ? 200 : 500);
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $exception) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($normalized === 'v1/widget/status' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
try {
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
|
||||
$positionTable = module_fn('boersenchecker', 'table', 'positions');
|
||||
$settings = modules()->settings('boersenchecker');
|
||||
|
||||
$latestAt = $pdo->query('SELECT MAX(quoted_at) FROM ' . $quoteTable)->fetchColumn();
|
||||
$countStmt = $pdo->query(
|
||||
'SELECT COUNT(DISTINCT i.id)
|
||||
FROM ' . $positionTable . ' p
|
||||
INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE i.symbol IS NOT NULL
|
||||
AND i.symbol <> \'\''
|
||||
);
|
||||
$trackedCount = (int) ($countStmt->fetchColumn() ?: 0);
|
||||
|
||||
$statusText = $latestAt
|
||||
? 'Letzter Kursabruf: ' . date('d.m.Y H:i', strtotime((string) $latestAt)) . ' · ' . $trackedCount . ' Titel'
|
||||
: 'Noch keine gespeicherten Kurse vorhanden.';
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'status_text' => $statusText,
|
||||
'latest_quoted_at' => $latestAt,
|
||||
'tracked_instruments' => $trackedCount,
|
||||
'report_currency' => (string) ($settings['report_currency'] ?? 'EUR'),
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $exception) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => 'Ressource nicht gefunden.',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
126
custom/apps/boersenchecker/assets/boersenchecker-native.js
Normal file
126
custom/apps/boersenchecker/assets/boersenchecker-native.js
Normal file
@@ -0,0 +1,126 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function toPartialUrl(input, fallbackView) {
|
||||
const url = new URL(input, window.location.origin);
|
||||
if (!url.searchParams.has('view') && fallbackView) {
|
||||
url.searchParams.set('view', fallbackView);
|
||||
}
|
||||
url.searchParams.set('partial', '1');
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchHtml(url, options) {
|
||||
const response = await fetch(url.toString(), {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function enhanceHost(host, state) {
|
||||
host.querySelectorAll('a[href]').forEach((link) => {
|
||||
const href = link.getAttribute('href') || '';
|
||||
if (!href.startsWith('/apps/boersenchecker')) {
|
||||
return;
|
||||
}
|
||||
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
state.load(href);
|
||||
});
|
||||
});
|
||||
|
||||
host.querySelectorAll('form').forEach((form) => {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const method = String(form.getAttribute('method') || 'get').toUpperCase();
|
||||
const action = form.getAttribute('action') || state.currentUrl.toString();
|
||||
const formData = new FormData(form);
|
||||
const url = toPartialUrl(action, state.defaultView);
|
||||
|
||||
try {
|
||||
if (method === 'GET') {
|
||||
const next = new URL(url.toString());
|
||||
next.search = '';
|
||||
next.searchParams.set('partial', '1');
|
||||
for (const [key, value] of formData.entries()) {
|
||||
next.searchParams.append(key, String(value));
|
||||
}
|
||||
await state.load(next.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await fetchHtml(url, {
|
||||
method,
|
||||
body: formData
|
||||
});
|
||||
state.render(html, url);
|
||||
} catch (error) {
|
||||
state.renderError(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.initBoersencheckerApp = function initBoersencheckerApp(host, options) {
|
||||
if (!(host instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryRoute = String(options?.entryRoute || '/apps/boersenchecker/index.php');
|
||||
const defaultView = String(options?.defaultView || 'overview');
|
||||
|
||||
const state = {
|
||||
defaultView,
|
||||
currentUrl: toPartialUrl(entryRoute, defaultView),
|
||||
async load(targetUrl) {
|
||||
const url = toPartialUrl(targetUrl, defaultView);
|
||||
this.currentUrl = url;
|
||||
host.innerHTML = '<div class="window-app-loading"><p>Börsenchecker wird geladen...</p></div>';
|
||||
try {
|
||||
const html = await fetchHtml(url);
|
||||
this.render(html, url);
|
||||
} catch (error) {
|
||||
this.renderError(error);
|
||||
}
|
||||
},
|
||||
render(html, url) {
|
||||
this.currentUrl = url;
|
||||
host.innerHTML = html;
|
||||
enhanceHost(host, this);
|
||||
if (typeof window.initBoersencheckerCharts === 'function') {
|
||||
try {
|
||||
window.initBoersencheckerCharts(host);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Chart-Initialisierung fehlgeschlagen.';
|
||||
const statusNode = host.querySelector('[data-bc-chart-status]');
|
||||
const chartNode = host.querySelector('[data-bc-chart]');
|
||||
if (statusNode instanceof HTMLElement) {
|
||||
statusNode.textContent = message;
|
||||
}
|
||||
if (chartNode instanceof HTMLElement) {
|
||||
chartNode.innerHTML = `<div class="muted">${message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderError(error) {
|
||||
const message = error instanceof Error ? error.message : 'Börsenchecker konnte nicht geladen werden.';
|
||||
host.innerHTML = `<div class="window-app-error-state"><p>${message}</p></div>`;
|
||||
}
|
||||
};
|
||||
|
||||
state.load(entryRoute);
|
||||
};
|
||||
})();
|
||||
402
custom/apps/boersenchecker/assets/boersenchecker.css
Normal file
402
custom/apps/boersenchecker/assets/boersenchecker.css
Normal file
@@ -0,0 +1,402 @@
|
||||
.bc-page {
|
||||
--bc-accent: var(--brand-accent);
|
||||
--bc-accent-strong: var(--brand-accent-2);
|
||||
--bc-ink: var(--text);
|
||||
--bc-text: var(--text);
|
||||
--bc-muted: var(--muted);
|
||||
--bc-line: var(--line);
|
||||
--bc-panel: rgba(255, 255, 255, 0.78);
|
||||
--bc-panel-soft: rgba(248, 252, 252, 0.92);
|
||||
--bc-positive: #137333;
|
||||
--bc-negative: #c62828;
|
||||
color: var(--bc-text);
|
||||
}
|
||||
|
||||
.bc-frame {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bc-sidebar {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bc-brand {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.bc-nav-list {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bc-nav-button {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bc-nav-button strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bc-main {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bc-panel-shell {
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.bc-hero {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bc-pill-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bc-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--bc-accent) 34%, transparent);
|
||||
background: color-mix(in srgb, var(--bc-accent) 10%, white);
|
||||
color: var(--bc-text);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bc-text {
|
||||
color: var(--bc-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bc-section-head {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bc-section-title {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.bc-section-head p,
|
||||
.bc-section-copy {
|
||||
color: var(--bc-muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.bc-form-card,
|
||||
.bc-panel,
|
||||
.bc-stat,
|
||||
.bc-chart-card,
|
||||
.bc-position-row {
|
||||
border: 1px solid var(--bc-line);
|
||||
border-radius: 22px;
|
||||
background: var(--bc-panel);
|
||||
box-shadow: 0 10px 24px rgba(1, 22, 32, 0.06);
|
||||
}
|
||||
|
||||
.bc-form-card,
|
||||
.bc-panel,
|
||||
.bc-chart-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.bc-stat {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bc-field-label {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--bc-muted);
|
||||
}
|
||||
|
||||
.bc-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bc-button,
|
||||
.bc-tabs a,
|
||||
.bc-page button,
|
||||
.bc-page input,
|
||||
.bc-page select,
|
||||
.bc-page textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.bc-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.bc-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bc-button--tab {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: var(--bc-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--tab-active {
|
||||
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
|
||||
color: #fff7fb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--primary {
|
||||
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
|
||||
color: #fff7fb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--secondary {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: var(--bc-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--ghost {
|
||||
background: color-mix(in srgb, var(--bc-accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--bc-accent) 34%, transparent);
|
||||
color: var(--bc-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bc-alert {
|
||||
padding: 16px 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.bc-alert--error {
|
||||
background: rgba(127, 29, 29, 0.28);
|
||||
border-color: rgba(252, 165, 165, 0.24);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.bc-alert--success {
|
||||
background: rgba(6, 78, 59, 0.28);
|
||||
border-color: rgba(134, 239, 172, 0.24);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.bc-toolbar,
|
||||
.bc-overview-grid,
|
||||
.bc-card-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bc-toolbar {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.bc-overview-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.bc-card-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.bc-stat-value {
|
||||
margin-top: 8px;
|
||||
font-size: 1.42rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-chart-shell {
|
||||
position: relative;
|
||||
min-height: 360px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bc-chart-svg {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bc-chart-path {
|
||||
fill: none;
|
||||
stroke: var(--bc-accent);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 12px 24px color-mix(in srgb, var(--bc-accent) 18%, transparent));
|
||||
}
|
||||
|
||||
.bc-chart-area {
|
||||
fill: url(#bc-chart-fill);
|
||||
}
|
||||
|
||||
.bc-chart-grid line {
|
||||
stroke: rgba(16, 33, 43, 0.08);
|
||||
stroke-dasharray: 4 6;
|
||||
}
|
||||
|
||||
.bc-range-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bc-range-button {
|
||||
border: 1px solid color-mix(in srgb, var(--bc-accent) 26%, transparent);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: var(--bc-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: transform .18s ease, background .18s ease, border-color .18s ease;
|
||||
}
|
||||
|
||||
.bc-range-button:hover,
|
||||
.bc-range-button[aria-pressed="true"] {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in srgb, var(--bc-accent) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--bc-accent) 42%, transparent);
|
||||
}
|
||||
|
||||
.bc-panel-fade {
|
||||
animation: bcPanelFade .35s ease;
|
||||
}
|
||||
|
||||
@keyframes bcPanelFade {
|
||||
from { opacity: 0; transform: translateY(8px) scale(.99); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.bc-position-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bc-position-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(96px, .72fr));
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.bc-performance {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-performance.is-positive {
|
||||
color: var(--bc-positive);
|
||||
}
|
||||
|
||||
.bc-performance.is-negative {
|
||||
color: var(--bc-negative);
|
||||
}
|
||||
|
||||
.bc-pill-soft {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bc-accent) 10%, transparent);
|
||||
color: var(--bc-text);
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.bc-table-shell {
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--bc-line);
|
||||
}
|
||||
|
||||
.bc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.bc-table th,
|
||||
.bc-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.bc-table thead {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.bc-page .setup-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bc-page input,
|
||||
.bc-page select,
|
||||
.bc-page textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--bc-line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-strong);
|
||||
color: var(--bc-text);
|
||||
}
|
||||
|
||||
.bc-page input::placeholder,
|
||||
.bc-page textarea::placeholder {
|
||||
color: color-mix(in srgb, var(--bc-muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.bc-page a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bc-page .muted {
|
||||
color: var(--bc-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.bc-frame {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bc-hero-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bc-position-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
175
custom/apps/boersenchecker/assets/boersenchecker.js
Normal file
175
custom/apps/boersenchecker/assets/boersenchecker.js
Normal file
@@ -0,0 +1,175 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function initBoersencheckerCharts(root) {
|
||||
const scope = root instanceof Element ? root : document;
|
||||
const app = scope.matches?.('[data-bc-home]') ? scope : scope.querySelector('[data-bc-home]');
|
||||
if (!app || app.dataset.bcChartBound === '1') return;
|
||||
app.dataset.bcChartBound = '1';
|
||||
|
||||
const chartShell = app.querySelector('[data-bc-chart]');
|
||||
const instrumentSelect = app.querySelector('[data-bc-instrument]');
|
||||
const instrumentNameNode = app.querySelector('[data-bc-instrument-name]');
|
||||
const instrumentMetaNode = app.querySelector('[data-bc-instrument-meta]');
|
||||
const rangeButtons = Array.from(app.querySelectorAll('[data-range]'));
|
||||
const statusNode = app.querySelector('[data-bc-chart-status]');
|
||||
const summaryNode = app.querySelector('[data-bc-chart-summary]');
|
||||
const endpoint = app.getAttribute('data-bc-chart-endpoint') || app.getAttribute('data-chart-endpoint') || '';
|
||||
const instrumentsScript = app.querySelector('[data-bc-instruments-json]');
|
||||
const instrumentMap = new Map();
|
||||
|
||||
if (instrumentsScript?.textContent) {
|
||||
try {
|
||||
const items = JSON.parse(instrumentsScript.textContent);
|
||||
if (Array.isArray(items)) {
|
||||
items.forEach((item) => instrumentMap.set(String(item.instrument_id), item));
|
||||
}
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
let activeRange = '1m';
|
||||
let currentPayload = null;
|
||||
|
||||
function pointsForRange(payload, range) {
|
||||
if (!payload) return [];
|
||||
const daily = payload.daily || [];
|
||||
const weekly = payload.weekly || [];
|
||||
const monthly = payload.monthly || [];
|
||||
switch (range) {
|
||||
case '1d': return daily.slice(-2);
|
||||
case '5d': return daily.slice(-5);
|
||||
case '1m': return daily.slice(-22);
|
||||
case '3m': return daily.slice(-66);
|
||||
case '6m': return weekly.slice(-26);
|
||||
case '1y': return weekly.slice(-52);
|
||||
case '5y': return monthly.slice(-60);
|
||||
default: return daily.slice(-22);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(points) {
|
||||
if (!chartShell) return;
|
||||
chartShell.classList.remove('bc-panel-fade');
|
||||
void chartShell.offsetWidth;
|
||||
chartShell.classList.add('bc-panel-fade');
|
||||
|
||||
if (!points || points.length === 0) {
|
||||
chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const values = points.map((point) => Number(point.close || 0));
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const width = 920;
|
||||
const height = 340;
|
||||
const paddingX = 24;
|
||||
const paddingY = 28;
|
||||
const usableWidth = width - paddingX * 2;
|
||||
const usableHeight = height - paddingY * 2;
|
||||
const spread = max - min || 1;
|
||||
|
||||
const coords = points.map((point, index) => {
|
||||
const x = paddingX + (usableWidth * index / Math.max(points.length - 1, 1));
|
||||
const y = paddingY + usableHeight - ((Number(point.close || 0) - min) / spread) * usableHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const path = coords.map((coord, index) => `${index === 0 ? 'M' : 'L'}${coord.x.toFixed(2)},${coord.y.toFixed(2)}`).join(' ');
|
||||
const area = `${path} L${coords[coords.length - 1].x.toFixed(2)},${height - paddingY} L${coords[0].x.toFixed(2)},${height - paddingY} Z`;
|
||||
const grid = [0, 1, 2, 3].map((step) => {
|
||||
const y = paddingY + (usableHeight * step / 3);
|
||||
return `<line x1="${paddingX}" y1="${y}" x2="${width - paddingX}" y2="${y}"></line>`;
|
||||
}).join('');
|
||||
|
||||
chartShell.innerHTML = `
|
||||
<svg class="bc-chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="bc-chart-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(94,234,212,0.32)"></stop>
|
||||
<stop offset="100%" stop-color="rgba(94,234,212,0.02)"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g class="bc-chart-grid">${grid}</g>
|
||||
<path class="bc-chart-area" d="${area}"></path>
|
||||
<path class="bc-chart-path" d="${path}"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
const percent = first !== 0 ? (delta / first) * 100 : 0;
|
||||
if (summaryNode) {
|
||||
summaryNode.textContent = `${last.toFixed(2)} | ${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${percent.toFixed(2)}%)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChart() {
|
||||
const instrumentId = instrumentSelect ? instrumentSelect.value : '';
|
||||
if (!instrumentId || !endpoint) {
|
||||
if (statusNode) statusNode.textContent = 'Keine Aktie fuer den Chart ausgewaehlt.';
|
||||
if (summaryNode) summaryNode.textContent = '-';
|
||||
if (chartShell) chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
|
||||
return;
|
||||
}
|
||||
if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...';
|
||||
try {
|
||||
const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
const responseText = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(responseText);
|
||||
} catch (_error) {
|
||||
const preview = responseText.trim().slice(0, 160);
|
||||
throw new Error(preview !== '' ? `Chart-API liefert kein JSON: ${preview}` : 'Chart-API liefert kein JSON.');
|
||||
}
|
||||
if (!payload.ok) {
|
||||
throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.');
|
||||
}
|
||||
currentPayload = payload;
|
||||
renderChart(pointsForRange(payload, activeRange));
|
||||
if (statusNode) {
|
||||
const sourceLabel = payload.source_label || payload.source || 'Lokale Kurshistorie';
|
||||
const instrumentRef = payload.symbol || payload.isin || '';
|
||||
statusNode.textContent = instrumentRef
|
||||
? `Quelle: ${sourceLabel} | ${instrumentRef}`
|
||||
: `Quelle: ${sourceLabel}`;
|
||||
}
|
||||
} catch (error) {
|
||||
currentPayload = null;
|
||||
if (chartShell) {
|
||||
chartShell.innerHTML = `<div class="muted">${error instanceof Error ? error.message : 'Chartdaten konnten nicht geladen werden.'}</div>`;
|
||||
}
|
||||
if (statusNode) statusNode.textContent = 'Fehler beim Laden der Chartdaten.';
|
||||
}
|
||||
}
|
||||
|
||||
rangeButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
activeRange = button.getAttribute('data-range') || '1m';
|
||||
rangeButtons.forEach((item) => item.setAttribute('aria-pressed', item === button ? 'true' : 'false'));
|
||||
renderChart(pointsForRange(currentPayload, activeRange));
|
||||
});
|
||||
});
|
||||
|
||||
if (instrumentSelect) {
|
||||
instrumentSelect.addEventListener('change', () => {
|
||||
const meta = instrumentMap.get(String(instrumentSelect.value));
|
||||
if (meta) {
|
||||
if (instrumentNameNode) instrumentNameNode.textContent = meta.instrument_name || 'Keine Aktie ausgewaehlt';
|
||||
if (instrumentMetaNode) instrumentMetaNode.textContent = `${meta.symbol || ''} · ${meta.isin || '-'}`;
|
||||
}
|
||||
loadChart();
|
||||
});
|
||||
}
|
||||
|
||||
loadChart();
|
||||
}
|
||||
|
||||
window.initBoersencheckerCharts = initBoersencheckerCharts;
|
||||
initBoersencheckerCharts(document);
|
||||
})();
|
||||
995
custom/apps/boersenchecker/bootstrap.php
Normal file
995
custom/apps/boersenchecker/bootstrap.php
Normal file
@@ -0,0 +1,995 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\ModuleConfigException;
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\Boersenchecker\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
$moduleName = 'boersenchecker';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
|
||||
$prefix = 'boersencheck_';
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return $prefix . $sanitized;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$useSeparate = !empty($settings['use_separate_db']);
|
||||
|
||||
if ($useSeparate) {
|
||||
$module = modules()->get($moduleName);
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
return modules()->modulePdo($moduleName, $fallback);
|
||||
}
|
||||
|
||||
$base = app()->basePdo();
|
||||
if ($base instanceof \PDO) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
throw new ModuleConfigException(
|
||||
$moduleName,
|
||||
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void {
|
||||
$pdo = module_fn($moduleName, 'pdo');
|
||||
$table = static fn (string $name): string => module_fn($moduleName, 'table', $name);
|
||||
$driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
|
||||
|
||||
$portfolioTable = $table('portfolios');
|
||||
$instrumentTable = $table('instruments');
|
||||
$positionTable = $table('positions');
|
||||
$quoteTable = $table('quotes');
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
isin VARCHAR(32) NULL,
|
||||
wkn VARCHAR(32) NULL,
|
||||
symbol VARCHAR(32) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
market VARCHAR(120) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
portfolio_id INTEGER NOT NULL,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
quantity NUMERIC(20,6) NOT NULL,
|
||||
purchase_price NUMERIC(20,8) NOT NULL,
|
||||
purchase_currency VARCHAR(10) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
fees NUMERIC(20,8) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
price NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
quoted_at TIMESTAMP NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
|
||||
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin) WHERE isin IS NOT NULL");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
|
||||
} elseif ($driver === 'mysql') {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$portfolioTable}_owner_idx (owner_sub)
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
isin VARCHAR(32) NULL,
|
||||
wkn VARCHAR(32) NULL,
|
||||
symbol VARCHAR(32) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
market VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY {$instrumentTable}_isin_uniq (isin),
|
||||
KEY {$instrumentTable}_symbol_idx (symbol)
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
portfolio_id INTEGER NOT NULL,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
quantity DECIMAL(20,6) NOT NULL,
|
||||
purchase_price DECIMAL(20,8) NOT NULL,
|
||||
purchase_currency VARCHAR(10) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
fees DECIMAL(20,8) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$positionTable}_owner_idx (owner_sub),
|
||||
KEY {$positionTable}_portfolio_idx (portfolio_id),
|
||||
KEY {$positionTable}_instrument_idx (instrument_id)
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
price DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
quoted_at DATETIME NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$quoteTable}_instrument_time_idx (instrument_id, quoted_at)
|
||||
)");
|
||||
} else {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
isin VARCHAR(32) NULL,
|
||||
wkn VARCHAR(32) NULL,
|
||||
symbol VARCHAR(32) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
market VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
portfolio_id INTEGER NOT NULL,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
quantity DECIMAL(20,6) NOT NULL,
|
||||
purchase_price DECIMAL(20,8) NOT NULL,
|
||||
purchase_currency VARCHAR(10) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
fees DECIMAL(20,8) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
price DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
quoted_at DATETIME NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
|
||||
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_service', static function (): ?object {
|
||||
if (modules()->isEnabled('fx-rates') && modules()->hasFunction('fx-rates', 'service')) {
|
||||
try {
|
||||
return module_fn('fx-rates', 'service');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array {
|
||||
$service = module_fn('boersenchecker', 'fx_service');
|
||||
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $service->ensureFreshLatestRates($maxAgeHours, strtoupper(trim($baseCurrency)) ?: 'EUR');
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => !empty($result['reused'])
|
||||
? 'Vorhandene FX-Daten weiterverwendet.'
|
||||
: 'FX-Daten aktualisiert.',
|
||||
'result' => $result,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_prepare_fetch', static function (
|
||||
string $baseCurrency = 'EUR',
|
||||
array $currencies = [],
|
||||
float $maxAgeHours = 6.0
|
||||
): array {
|
||||
$service = module_fn('boersenchecker', 'fx_service');
|
||||
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
|
||||
];
|
||||
}
|
||||
|
||||
$baseCurrency = strtoupper(trim($baseCurrency)) ?: 'EUR';
|
||||
$currencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
$currencies
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
|
||||
try {
|
||||
$result = $service->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => !empty($result['reused']) ? 'Vorhandene FX-Daten weiterverwendet.' : 'FX-Daten aktualisiert.',
|
||||
'result' => $result,
|
||||
'fetch_id' => is_numeric($result['fetch_id'] ?? null) ? (int) $result['fetch_id'] : null,
|
||||
'reused' => !empty($result['reused']),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_source_with_fetch_id', static function (string $source, ?int $fetchId = null): string {
|
||||
$source = trim($source) !== '' ? trim($source) : 'manual';
|
||||
if ($fetchId === null || $fetchId <= 0) {
|
||||
return preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
|
||||
}
|
||||
|
||||
$source = preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
|
||||
return $source . '|fx_fetch:' . $fetchId;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_extract_fetch_id', static function (?string $source): ?int {
|
||||
$source = trim((string) $source);
|
||||
if ($source === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\|fx_fetch:(\d+)$/', $source, $matches) === 1) {
|
||||
$fetchId = (int) ($matches[1] ?? 0);
|
||||
return $fetchId > 0 ? $fetchId : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_convert_with_fetch', static function (
|
||||
?float $amount,
|
||||
?string $fromCurrency,
|
||||
?string $toCurrency,
|
||||
?int $fetchId = null
|
||||
): ?float {
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = strtoupper(trim((string) $fromCurrency));
|
||||
$to = strtoupper(trim((string) $toCurrency));
|
||||
if ($from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$service = module_fn('boersenchecker', 'fx_service');
|
||||
if (!$service) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
if ($normalizedFetchId !== null && method_exists($service, 'snapshotByFetchId')) {
|
||||
try {
|
||||
$snapshot = $service->snapshotByFetchId($normalizedFetchId, null, [$from, $to]);
|
||||
if (is_array($snapshot)) {
|
||||
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$fromRate = $from === $base ? 1.0 : (is_numeric($rates[$from] ?? null) ? (float) $rates[$from] : null);
|
||||
$toRate = $to === $base ? 1.0 : (is_numeric($rates[$to] ?? null) ? (float) $rates[$to] : null);
|
||||
if ($fromRate !== null && $fromRate > 0 && $toRate !== null && $toRate > 0) {
|
||||
return $amount * ($toRate / $fromRate);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!method_exists($service, 'convert')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = $service->convert($amount, $from, $to);
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_request', static function (
|
||||
string $functionName,
|
||||
array $params = []
|
||||
): array {
|
||||
$settings = modules()->settings('boersenchecker');
|
||||
$apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? ''));
|
||||
$timeout = (int) (($settings['alpha_vantage_timeout_sec'] ?? null) ?: 12);
|
||||
$timeout = $timeout > 0 ? $timeout : 12;
|
||||
|
||||
if ($apiKey === '') {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'params' => $params,
|
||||
],
|
||||
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
|
||||
];
|
||||
}
|
||||
|
||||
$url = 'https://www.alphavantage.co/query?' . http_build_query(array_merge([
|
||||
'function' => $functionName,
|
||||
'apikey' => $apiKey,
|
||||
], $params), '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
$responseBody = null;
|
||||
$httpCode = 0;
|
||||
$curlError = '';
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
if ($ch !== false) {
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
$responseBody = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_string($responseBody) || $responseBody === '') {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => $timeout,
|
||||
'header' => "Accept: application/json\r\n",
|
||||
],
|
||||
]);
|
||||
$responseBody = @file_get_contents($url, false, $context);
|
||||
}
|
||||
|
||||
if (!is_string($responseBody) || $responseBody === '') {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'curl_error' => $curlError,
|
||||
'body' => null,
|
||||
],
|
||||
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.',
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.'
|
||||
. ($curlError !== '' ? ' ' . $curlError : '')
|
||||
. ($httpCode > 0 ? ' HTTP ' . $httpCode : ''),
|
||||
];
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseBody, true);
|
||||
if (!is_array($decoded)) {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'body_preview' => substr($responseBody, 0, 4000),
|
||||
],
|
||||
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
|
||||
'raw_body' => $responseBody,
|
||||
];
|
||||
}
|
||||
|
||||
foreach (['Error Message', 'Information', 'Note'] as $errorKey) {
|
||||
if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'body' => $decoded,
|
||||
],
|
||||
'message' => trim((string) $decoded[$errorKey]),
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => trim((string) $decoded[$errorKey]),
|
||||
'raw' => $decoded,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:response',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'body' => $decoded,
|
||||
],
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'data' => $decoded,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'display_timezone', static function (): \DateTimeZone {
|
||||
return new \DateTimeZone(nexus_display_timezone_name());
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'normalize_market_timestamp_utc', static function (mixed $value): string {
|
||||
if (is_numeric($value)) {
|
||||
return gmdate('Y-m-d H:i:s', (int) $value);
|
||||
}
|
||||
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
|
||||
return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
$timestamp = strtotime($raw);
|
||||
return $timestamp !== false ? gmdate('Y-m-d H:i:s', $timestamp) : gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'format_datetime_for_display', static function (
|
||||
?string $value,
|
||||
?string $source = null,
|
||||
string $format = 'Y-m-d H:i:s'
|
||||
): string {
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$displayTimezone = new \DateTimeZone(nexus_display_timezone_name());
|
||||
$source = trim((string) $source);
|
||||
|
||||
if (str_starts_with($source, 'bavest:') || str_starts_with($source, 'alphavantage:')) {
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new \DateTimeZone('UTC'));
|
||||
if (!$date instanceof \DateTimeImmutable) {
|
||||
try {
|
||||
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
|
||||
} catch (\Throwable) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
return $date->setTimezone($displayTimezone)->format($format);
|
||||
}
|
||||
|
||||
if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $raw) === 1) {
|
||||
try {
|
||||
return (new \DateTimeImmutable($raw))->setTimezone($displayTimezone)->format($format);
|
||||
} catch (\Throwable) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
|
||||
return str_replace('T', ' ', $raw);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'local_now_input_value', static function (): string {
|
||||
return (new \DateTimeImmutable('now', new \DateTimeZone(nexus_display_timezone_name())))->format('Y-m-d\TH:i');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_extract_global_quote', static function (array $entry): ?array {
|
||||
$quote = is_array($entry['Global Quote'] ?? null) ? $entry['Global Quote'] : $entry;
|
||||
$price = $quote['05. price'] ?? null;
|
||||
if (!is_numeric($price)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'symbol' => trim((string) ($quote['01. symbol'] ?? '')),
|
||||
'price' => (float) $price,
|
||||
'currency' => '',
|
||||
'fetched_at' => gmdate('Y-m-d H:i:s'),
|
||||
'market_date' => trim((string) ($quote['07. latest trading day'] ?? '')),
|
||||
'source' => 'alphavantage:global_quote',
|
||||
'raw' => $quote,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote_by_symbol', static function (string $symbol): array {
|
||||
$symbol = strtoupper(trim($symbol));
|
||||
if ($symbol === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Kein Symbol hinterlegt.',
|
||||
];
|
||||
}
|
||||
|
||||
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'GLOBAL_QUOTE', [
|
||||
'symbol' => $symbol,
|
||||
]);
|
||||
if (empty($response['ok'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$quote = module_fn('boersenchecker', 'alpha_vantage_extract_global_quote', (array) ($response['data'] ?? []));
|
||||
if (!is_array($quote)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.',
|
||||
];
|
||||
}
|
||||
|
||||
return ['ok' => true] + $quote;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function (array $instruments): array {
|
||||
$quotes = [];
|
||||
$errors = [];
|
||||
foreach ($instruments as $instrument) {
|
||||
if (!is_array($instrument)) {
|
||||
continue;
|
||||
}
|
||||
$instrumentId = (int) ($instrument['id'] ?? 0);
|
||||
$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
|
||||
if ($instrumentId <= 0 || $symbol === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
|
||||
if (empty($result['ok'])) {
|
||||
$errors[] = $symbol . ': ' . (string) ($result['message'] ?? 'API-Abruf fehlgeschlagen.');
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotes[$instrumentId] = $result + ['instrument_id' => $instrumentId];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'quotes' => $quotes,
|
||||
'errors' => $errors,
|
||||
'message' => count($quotes) . ' Kurse ueber Alpha Vantage geladen.',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function (array $context = []): array {
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
$settings = modules()->settings('boersenchecker');
|
||||
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
|
||||
$positionTable = module_fn('boersenchecker', 'table', 'positions');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
|
||||
$defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
|
||||
$minIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
|
||||
if ($minIntervalMinutes <= 0) {
|
||||
$minIntervalMinutes = 60;
|
||||
}
|
||||
|
||||
$stmt = $pdo->query(
|
||||
'SELECT DISTINCT
|
||||
i.id,
|
||||
i.name,
|
||||
i.symbol,
|
||||
i.quote_currency
|
||||
FROM ' . $positionTable . ' p
|
||||
INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE i.symbol IS NOT NULL
|
||||
AND i.symbol <> \'\'
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
if ($rows === []) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Kein automatischer Kursabruf: keine Aktien mit Symbol vorhanden.',
|
||||
];
|
||||
}
|
||||
|
||||
$instrumentIds = array_values(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows));
|
||||
$instrumentIds = array_values(array_filter($instrumentIds, static fn (int $id): bool => $id > 0));
|
||||
$latestQuotes = [];
|
||||
if ($instrumentIds !== []) {
|
||||
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
|
||||
$latestStmt = $pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $quoteTable . '
|
||||
WHERE instrument_id IN (' . $placeholders . ')
|
||||
AND source LIKE ?
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC'
|
||||
);
|
||||
$latestStmt->execute([...$instrumentIds, 'alphavantage:%']);
|
||||
foreach ($latestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$instrumentId = (int) ($row['instrument_id'] ?? 0);
|
||||
if ($instrumentId > 0 && !isset($latestQuotes[$instrumentId])) {
|
||||
$latestQuotes[$instrumentId] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$reused = 0;
|
||||
$candidates = [];
|
||||
foreach ($rows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$latest = $latestQuotes[$instrumentId] ?? null;
|
||||
$latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($minIntervalMinutes * 60)) {
|
||||
$reused++;
|
||||
continue;
|
||||
}
|
||||
$candidates[] = $row;
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Automatischer Kursabruf uebersprungen: alle Kurse liegen noch innerhalb des Mindestabstands.',
|
||||
];
|
||||
}
|
||||
|
||||
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => strtoupper(trim((string) ($row['quote_currency'] ?? ''))),
|
||||
$candidates
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
$fxResult = module_fn('boersenchecker', 'fx_prepare_fetch', $defaultReportCurrency, $quoteCurrencies, (float) (($settings['fx_max_age_hours'] ?? null) ?: 6));
|
||||
if (empty($fxResult['ok'])) {
|
||||
return $fxResult;
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
|
||||
$bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates);
|
||||
if (empty($bulkResult['ok'])) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => (string) ($bulkResult['message'] ?? 'Automatischer Alpha-Vantage-Abruf fehlgeschlagen.'),
|
||||
];
|
||||
}
|
||||
|
||||
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
|
||||
$errors = is_array($bulkResult['errors'] ?? null) ? $bulkResult['errors'] : [];
|
||||
$updated = 0;
|
||||
foreach ($candidates as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$quote = $quotes[$instrumentId] ?? null;
|
||||
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$storeResult = module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $quote['price'],
|
||||
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $defaultReportCurrency))) ?: $defaultReportCurrency,
|
||||
(string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')),
|
||||
(string) module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
$updated++;
|
||||
} else {
|
||||
$reused++;
|
||||
}
|
||||
}
|
||||
|
||||
$message = 'Automatischer Kursabruf: ' . $updated . ' neu, ' . $reused . ' wiederverwendet, ' . count($errors) . ' Fehler.';
|
||||
if ($errors !== []) {
|
||||
$message .= ' ' . implode(' | ', array_slice($errors, 0, 3));
|
||||
}
|
||||
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Intervall-Aufgabe',
|
||||
'type' => 'scheduler:run',
|
||||
'task' => 'auto_refresh_quotes',
|
||||
'context' => $context,
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => $errors === [],
|
||||
'message' => $message,
|
||||
'updated' => $updated,
|
||||
'reused' => $reused,
|
||||
'errors' => $errors,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array {
|
||||
$keywords = trim($keywords);
|
||||
if ($keywords === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Bitte Suchbegriff angeben.',
|
||||
'results' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'SYMBOL_SEARCH', [
|
||||
'keywords' => $keywords,
|
||||
]);
|
||||
if (empty($response['ok'])) {
|
||||
return $response + ['results' => []];
|
||||
}
|
||||
|
||||
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||
$items = is_array($data['bestMatches'] ?? null) ? $data['bestMatches'] : [];
|
||||
$results = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$symbol = trim((string) ($item['1. symbol'] ?? ''));
|
||||
$name = trim((string) ($item['2. name'] ?? ''));
|
||||
$type = trim((string) ($item['3. type'] ?? ''));
|
||||
$region = trim((string) ($item['4. region'] ?? ''));
|
||||
$currency = strtoupper(trim((string) ($item['8. currency'] ?? '')));
|
||||
$matchScore = trim((string) ($item['9. matchScore'] ?? ''));
|
||||
if ($symbol === '' && $name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'symbol' => $symbol,
|
||||
'name' => $name,
|
||||
'isin' => '',
|
||||
'type' => $type,
|
||||
'region' => $region,
|
||||
'currency' => $currency,
|
||||
'match_score' => $matchScore,
|
||||
'raw' => $item,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => count($results) . ' Treffer gefunden.',
|
||||
'results' => $results,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_chart_series', static function (string $symbol): array {
|
||||
$symbol = strtoupper(trim($symbol));
|
||||
if ($symbol === '') {
|
||||
return ['ok' => false, 'message' => 'Kein Symbol angegeben.'];
|
||||
}
|
||||
|
||||
$cacheDir = sys_get_temp_dir() . '/boersenchecker-alphavantage';
|
||||
if (!is_dir($cacheDir)) {
|
||||
@mkdir($cacheDir, 0775, true);
|
||||
}
|
||||
$cachePath = $cacheDir . '/' . md5('time_series_daily_adjusted|' . $symbol) . '.json';
|
||||
|
||||
$decoded = null;
|
||||
if (is_file($cachePath) && (time() - filemtime($cachePath)) < (6 * 3600)) {
|
||||
$cached = file_get_contents($cachePath);
|
||||
$decoded = is_string($cached) ? json_decode($cached, true) : null;
|
||||
}
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'TIME_SERIES_DAILY_ADJUSTED', [
|
||||
'symbol' => $symbol,
|
||||
'outputsize' => 'full',
|
||||
]);
|
||||
if (empty($response['ok'])) {
|
||||
return $response;
|
||||
}
|
||||
$decoded = is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||
@file_put_contents($cachePath, json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
$rows = is_array($decoded['Time Series (Daily)'] ?? null)
|
||||
? $decoded['Time Series (Daily)']
|
||||
: (is_array($decoded['Time Series (Daily) Adjusted'] ?? null) ? $decoded['Time Series (Daily) Adjusted'] : []);
|
||||
|
||||
$daily = [];
|
||||
foreach ($rows as $date => $row) {
|
||||
if (!is_array($row) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $date)) {
|
||||
continue;
|
||||
}
|
||||
$close = $row['5. adjusted close'] ?? $row['4. close'] ?? null;
|
||||
if (!is_numeric($close)) {
|
||||
continue;
|
||||
}
|
||||
$daily[] = [
|
||||
'date' => $date,
|
||||
'close' => (float) $close,
|
||||
];
|
||||
}
|
||||
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
|
||||
if ($daily === []) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Keine historischen Schlusskurse fuer ' . $symbol . ' verfuegbar.',
|
||||
];
|
||||
}
|
||||
|
||||
$aggregate = static function (array $points, string $format): array {
|
||||
$result = [];
|
||||
foreach ($points as $point) {
|
||||
$bucket = date($format, strtotime((string) $point['date']) ?: time());
|
||||
$result[$bucket] = $point;
|
||||
}
|
||||
return array_values($result);
|
||||
};
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'symbol' => $symbol,
|
||||
'daily' => $daily,
|
||||
'weekly' => $aggregate($daily, 'o-W'),
|
||||
'monthly' => $aggregate($daily, 'Y-m'),
|
||||
'source' => 'alphavantage:time_series_daily_adjusted',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'store_market_quote', static function (
|
||||
int $instrumentId,
|
||||
float $price,
|
||||
string $currency,
|
||||
string $quotedAt,
|
||||
string $source
|
||||
): array {
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
|
||||
$quotedAt = trim($quotedAt);
|
||||
$currency = strtoupper(trim($currency)) ?: 'EUR';
|
||||
$source = trim($source) !== '' ? trim($source) : 'alphavantage:global_quote';
|
||||
|
||||
$checkStmt = $pdo->prepare(
|
||||
'SELECT id
|
||||
FROM ' . $quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
AND price = :price
|
||||
AND currency = :currency
|
||||
AND quoted_at = :quoted_at
|
||||
AND source = :source
|
||||
LIMIT 1'
|
||||
);
|
||||
$checkStmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
]);
|
||||
$existingId = (int) $checkStmt->fetchColumn();
|
||||
if ($existingId > 0) {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Quote Store',
|
||||
'type' => 'quote:reuse',
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
'message' => 'Identischer Snapshot bereits vorhanden.',
|
||||
]);
|
||||
return ['ok' => true, 'inserted' => false, 'id' => $existingId];
|
||||
}
|
||||
|
||||
$insertStmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $quoteTable . ' (instrument_id, price, currency, quoted_at, source)
|
||||
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
|
||||
);
|
||||
$insertStmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
]);
|
||||
|
||||
$insertedId = (int) $pdo->lastInsertId();
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Quote Store',
|
||||
'type' => 'quote:insert',
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
'inserted_id' => $insertedId,
|
||||
'message' => 'Neuer Snapshot gespeichert.',
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'inserted' => true, 'id' => $insertedId];
|
||||
});
|
||||
30
custom/apps/boersenchecker/config/module.php
Normal file
30
custom/apps/boersenchecker/config/module.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\ConfigLoader;
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
$baseDb = ConfigLoader::load($projectRoot, 'base_db');
|
||||
$baseDbConfig = (bool) ($baseDb['enabled'] ?? false) && is_array($baseDb['db'] ?? null)
|
||||
? $baseDb['db']
|
||||
: ConfigLoader::load($projectRoot, 'db_settings_basic');
|
||||
|
||||
return [
|
||||
'use_separate_db' => filter_var(getenv('BOERSENCHECKER_USE_SEPARATE_DB') ?: 'false', FILTER_VALIDATE_BOOL),
|
||||
'db' => [
|
||||
'driver' => getenv('BOERSENCHECKER_DB_DRIVER') ?: (string) ($baseDbConfig['driver'] ?? 'pgsql'),
|
||||
'host' => getenv('BOERSENCHECKER_DB_HOST') ?: (string) ($baseDbConfig['host'] ?? 'localhost'),
|
||||
'port' => (int) (getenv('BOERSENCHECKER_DB_PORT') ?: (int) ($baseDbConfig['port'] ?? 5432)),
|
||||
'dbname' => getenv('BOERSENCHECKER_DB_NAME') ?: (string) ($baseDbConfig['dbname'] ?? ''),
|
||||
'schema' => getenv('BOERSENCHECKER_DB_SCHEMA') ?: (string) ($baseDbConfig['schema'] ?? 'public'),
|
||||
'user' => getenv('BOERSENCHECKER_DB_USER') ?: (string) ($baseDbConfig['user'] ?? ''),
|
||||
'password' => getenv('BOERSENCHECKER_DB_PASSWORD') ?: (string) ($baseDbConfig['password'] ?? ''),
|
||||
],
|
||||
'report_currency' => getenv('BOERSENCHECKER_REPORT_CURRENCY') ?: 'EUR',
|
||||
'fx_max_age_hours' => (float) (getenv('BOERSENCHECKER_FX_MAX_AGE_HOURS') ?: 6),
|
||||
'alpha_vantage_api_key' => getenv('BOERSENCHECKER_ALPHA_VANTAGE_API_KEY') ?: (getenv('ALPHA_VANTAGE_API_KEY') ?: ''),
|
||||
'alpha_vantage_timeout_sec' => (int) (getenv('BOERSENCHECKER_ALPHA_VANTAGE_TIMEOUT_SEC') ?: 12),
|
||||
'alpha_vantage_min_interval_minutes' => (int) (getenv('BOERSENCHECKER_ALPHA_VANTAGE_MIN_INTERVAL_MINUTES') ?: 60),
|
||||
'auto_refresh_quotes_enabled' => filter_var(getenv('BOERSENCHECKER_AUTO_REFRESH_QUOTES_ENABLED') ?: 'false', FILTER_VALIDATE_BOOL),
|
||||
'auto_refresh_quotes_interval_hours' => (int) (getenv('BOERSENCHECKER_AUTO_REFRESH_QUOTES_INTERVAL_HOURS') ?: 6),
|
||||
];
|
||||
15
custom/apps/boersenchecker/design.json
Normal file
15
custom/apps/boersenchecker/design.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Boersenchecker",
|
||||
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
|
||||
"actions": [
|
||||
{ "label": "Desktop", "href": "/" },
|
||||
{ "label": "Setup", "href": "/apps/boersenchecker/index.php?view=setup" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Ueberblick", "href": "/apps/boersenchecker/index.php?view=overview", "view": "overview" },
|
||||
{ "label": "Depotverwaltung", "href": "/apps/boersenchecker/index.php?view=depotverwaltung", "view": "depotverwaltung" },
|
||||
{ "label": "Aktienverwaltung", "href": "/apps/boersenchecker/index.php?view=aktienverwaltung", "view": "aktienverwaltung" },
|
||||
{ "label": "Setup", "href": "/apps/boersenchecker/index.php?view=setup", "view": "setup" }
|
||||
]
|
||||
}
|
||||
41
custom/apps/boersenchecker/desktop.php
Normal file
41
custom/apps/boersenchecker/desktop.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$manifest = [];
|
||||
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
|
||||
if ($manifestRaw !== false) {
|
||||
$decoded = json_decode($manifestRaw, true);
|
||||
$manifest = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return [
|
||||
'app_id' => 'boersenchecker',
|
||||
'title' => (string) ($manifest['title'] ?? 'Börsenchecker'),
|
||||
'icon' => 'BC',
|
||||
'entry_route' => '/apps/boersenchecker/index.php',
|
||||
'window_mode' => 'window',
|
||||
'content_mode' => 'native-module',
|
||||
'default_width' => 1220,
|
||||
'default_height' => 860,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => false,
|
||||
'module_name' => 'finance',
|
||||
'summary' => (string) ($manifest['description'] ?? 'Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.'),
|
||||
'native_module' => [
|
||||
'initializer' => 'initBoersencheckerApp',
|
||||
'options' => [
|
||||
'entryRoute' => '/apps/boersenchecker/index.php',
|
||||
'defaultView' => 'overview',
|
||||
],
|
||||
'assets' => [
|
||||
'styles' => [
|
||||
['href' => '/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/boersenchecker.css') ? filemtime(__DIR__ . '/assets/boersenchecker.css') : '0'))],
|
||||
],
|
||||
'scripts' => [
|
||||
['src' => '/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/boersenchecker.js') ? filemtime(__DIR__ . '/assets/boersenchecker.js') : '0'))],
|
||||
['src' => '/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker-native.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/boersenchecker-native.js') ? filemtime(__DIR__ . '/assets/boersenchecker-native.js') : '0'))],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
20
custom/apps/boersenchecker/docs/README.md
Normal file
20
custom/apps/boersenchecker/docs/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Börsenchecker
|
||||
|
||||
Importiertes Altmodul fuer Depotverwaltung, Aktienstammdaten, Kursverlauf, Alpha-Vantage-Abrufe und FX-Umrechnung.
|
||||
|
||||
## Einbindung
|
||||
|
||||
- Desktop-App ueber `custom/apps/boersenchecker/desktop.php`
|
||||
- oeffentlicher App-Einstieg ueber `public/apps/boersenchecker/index.php`
|
||||
- API-Endpunkt fuer Chartdaten ueber `public/api/boersenchecker/index.php?path=v1/chart-data`
|
||||
|
||||
## Konfiguration
|
||||
|
||||
- Standardwerte liegen in `config/module.php`
|
||||
- gespeicherte Modul-Settings liegen unter `data/module-settings/boersenchecker.json`
|
||||
- das Setup innerhalb der App bildet die alten Nexus-Setup-Felder ab
|
||||
|
||||
## Altlogik
|
||||
|
||||
- Fachlogik, Partials und Alpha-Vantage-/FX-Helfer stammen aus dem alten Nexus-Modul
|
||||
- die Legacy-Kompatibilitaet wird ueber `src/ModulesCore/LegacyCompat.php` bereitgestellt
|
||||
97
custom/apps/boersenchecker/module.json
Normal file
97
custom/apps/boersenchecker/module.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"title": "Börsenchecker",
|
||||
"version": "0.2.0",
|
||||
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
|
||||
"enabled_by_default": true,
|
||||
"app_scope": "module",
|
||||
"installable": true,
|
||||
"desktop": {
|
||||
"available": true,
|
||||
"show_on_desktop": true,
|
||||
"show_in_start_menu": true
|
||||
},
|
||||
"widgets": [
|
||||
{
|
||||
"widget_id": "boersenchecker-refresh",
|
||||
"title": "Boersenkurse",
|
||||
"icon": "BC",
|
||||
"zone": "sidebar",
|
||||
"default_enabled": false,
|
||||
"supports_public_home": false,
|
||||
"summary": "Kurse fuer hinterlegte Depot-Positionen manuell nachziehen.",
|
||||
"content": "Widget fuer Schnellabruf des letzten Kursstatus und manuellen Marktrefresh.",
|
||||
"launch_app_id": "boersenchecker",
|
||||
"action_label": "App oeffnen",
|
||||
"widget_type": "action-panel",
|
||||
"status_api": "/api/boersenchecker/index.php?path=v1/widget/status",
|
||||
"actions": [
|
||||
{
|
||||
"action_id": "refresh-quotes",
|
||||
"label": "Kurse abrufen",
|
||||
"endpoint": "/api/boersenchecker/index.php?path=v1/widget/refresh-quotes",
|
||||
"method": "POST"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cron_jobs": [
|
||||
{
|
||||
"job_id": "refresh-quotes",
|
||||
"label": "Boersenkurse aktualisieren",
|
||||
"description": "Laedt fuer alle aktiven Depot-Positionen neue Alpha-Vantage-Kurse.",
|
||||
"endpoint_path": "/api/boersenchecker/index.php?path=v1/cron/refresh-quotes",
|
||||
"method": "POST",
|
||||
"payload": {
|
||||
"trigger_source": "cron"
|
||||
},
|
||||
"default_enabled": false,
|
||||
"default_cron": "15 */6 * * 1-5",
|
||||
"lock_minutes": 20
|
||||
}
|
||||
],
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." },
|
||||
{ "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber das Modul fx-rates genutzt." },
|
||||
{ "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Alpha Vantage." },
|
||||
{ "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." },
|
||||
{ "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." },
|
||||
{ "name": "auto_refresh_quotes_enabled", "label": "Automatischen Kursabruf aktivieren", "type": "checkbox", "required": false, "help": "Fuehrt Kursupdates automatisch beim ersten Modulaufruf nach Ablauf des Intervalls aus." },
|
||||
{ "name": "auto_refresh_quotes_interval_hours", "label": "Intervall fuer automatischen Kursabruf (Stunden)", "type": "number", "required": false, "help": "Nach Ablauf dieses Intervalls wird beim naechsten Modulaufruf ein automatischer Kursabruf gestartet." }
|
||||
]
|
||||
},
|
||||
"interval_tasks": [
|
||||
{
|
||||
"name": "auto_refresh_quotes",
|
||||
"label": "Automatischer Kursabruf",
|
||||
"callback": "scheduled_refresh_quotes",
|
||||
"enabled_setting": "auto_refresh_quotes_enabled",
|
||||
"interval_setting": "auto_refresh_quotes_interval_hours",
|
||||
"default_enabled": false,
|
||||
"default_interval_hours": 6,
|
||||
"lock_minutes": 20
|
||||
}
|
||||
],
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
358
custom/apps/boersenchecker/pages/index.php
Normal file
358
custom/apps/boersenchecker/pages/index.php
Normal file
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
ModuleHttp::requireDesktopAccess($projectRoot);
|
||||
$isPartial = !empty($_GET['partial']);
|
||||
|
||||
$view = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
$allowedViews = ['overview', 'depotverwaltung', 'aktienverwaltung', 'setup'];
|
||||
if (!in_array($view, $allowedViews, true)) {
|
||||
$view = 'overview';
|
||||
}
|
||||
|
||||
$viewMeta = [
|
||||
'overview' => [
|
||||
'label' => 'Ueberblick',
|
||||
'title' => 'Depot-Ueberblick',
|
||||
'description' => 'Kennzahlen, Kursverlauf und Performance fuer das ausgewaehlte Depot.',
|
||||
'nav_description' => 'Marktueberblick, Positionen und gespeicherte Kurse.',
|
||||
],
|
||||
'depotverwaltung' => [
|
||||
'label' => 'Depotverwaltung',
|
||||
'title' => 'Depotverwaltung',
|
||||
'description' => 'Depots, Positionen und manuelle Kursdaten zentral verwalten.',
|
||||
'nav_description' => 'Depots pflegen, FX abrufen und Positionen bearbeiten.',
|
||||
],
|
||||
'aktienverwaltung' => [
|
||||
'label' => 'Aktienverwaltung',
|
||||
'title' => 'Aktienverwaltung',
|
||||
'description' => 'Wertpapier-Stammdaten, Suche und Kursverlauf pro Aktie pflegen.',
|
||||
'nav_description' => 'Ticker, ISIN, Markt und Kursverlauf pro Instrument.',
|
||||
],
|
||||
'setup' => [
|
||||
'label' => 'Setup',
|
||||
'title' => 'Setup',
|
||||
'description' => 'API-Zugaenge, Datenbankoptionen und Modulvorgaben verwalten.',
|
||||
'nav_description' => 'Schluessel, Timeouts und Modulkonfiguration speichern.',
|
||||
],
|
||||
];
|
||||
|
||||
$assetVersion = static function (string $relativePath): string {
|
||||
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
|
||||
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
|
||||
|
||||
return $mtime === false ? '0' : (string) $mtime;
|
||||
};
|
||||
|
||||
$renderSetup = static function (): void {
|
||||
$moduleName = 'boersenchecker';
|
||||
$manifestPath = dirname(__DIR__) . '/module.json';
|
||||
$manifestRaw = is_file($manifestPath) ? file_get_contents($manifestPath) : false;
|
||||
$manifest = is_string($manifestRaw) && trim($manifestRaw) !== '' ? json_decode($manifestRaw, true) : [];
|
||||
$fields = is_array($manifest['setup']['fields'] ?? null) ? $manifest['setup']['fields'] : [];
|
||||
$settings = modules()->settings($moduleName);
|
||||
$notice = null;
|
||||
$error = null;
|
||||
|
||||
$getValue = static function (array $source, string $path): mixed {
|
||||
$value = $source;
|
||||
foreach (explode('.', $path) as $segment) {
|
||||
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$segment];
|
||||
}
|
||||
|
||||
return $value;
|
||||
};
|
||||
|
||||
$setValue = static function (array &$target, string $path, mixed $value): void {
|
||||
$segments = explode('.', $path);
|
||||
$cursor = &$target;
|
||||
foreach ($segments as $index => $segment) {
|
||||
if ($index === count($segments) - 1) {
|
||||
$cursor[$segment] = $value;
|
||||
return;
|
||||
}
|
||||
if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) {
|
||||
$cursor[$segment] = [];
|
||||
}
|
||||
$cursor = &$cursor[$segment];
|
||||
}
|
||||
};
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'save_setup') {
|
||||
try {
|
||||
$nextSettings = $settings;
|
||||
foreach ($fields as $field) {
|
||||
if (!is_array($field)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($field['name'] ?? ''));
|
||||
$type = trim((string) ($field['type'] ?? 'text'));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$raw = $_POST['settings'][$name] ?? null;
|
||||
$value = match ($type) {
|
||||
'checkbox' => $raw !== null,
|
||||
'number' => ($raw === null || $raw === '') ? null : (is_numeric((string) $raw) ? 0 + $raw : null),
|
||||
default => is_string($raw) ? trim($raw) : '',
|
||||
};
|
||||
$setValue($nextSettings, $name, $value);
|
||||
}
|
||||
modules()->saveSettings($moduleName, $nextSettings);
|
||||
$settings = modules()->settings($moduleName);
|
||||
$notice = 'Setup gespeichert.';
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
<div class="bc-page">
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Modul-Setup</h2>
|
||||
<p>Die Felder entsprechen dem alten Nexus-Modul und werden lokal in `data/module-settings/boersenchecker.json` gespeichert.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_setup">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(260px, 1fr)); gap:12px;">
|
||||
<?php foreach ($fields as $field): ?>
|
||||
<?php
|
||||
if (!is_array($field)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($field['name'] ?? ''));
|
||||
$label = trim((string) ($field['label'] ?? $name));
|
||||
$type = trim((string) ($field['type'] ?? 'text'));
|
||||
$help = trim((string) ($field['help'] ?? ''));
|
||||
$value = $getValue($settings, $name);
|
||||
?>
|
||||
<label class="setup-field muted">
|
||||
<span><?= e($label) ?></span>
|
||||
<?php if ($type === 'checkbox'): ?>
|
||||
<input type="checkbox" name="settings[<?= e($name) ?>]" value="1" <?= !empty($value) ? 'checked' : '' ?>>
|
||||
<?php elseif ($type === 'password'): ?>
|
||||
<input type="password" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
|
||||
<?php elseif ($type === 'number'): ?>
|
||||
<input type="number" step="any" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
|
||||
<?php else: ?>
|
||||
<input type="text" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($help !== ''): ?>
|
||||
<small class="bc-text"><?= e($help) ?></small>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Setup speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<?php
|
||||
};
|
||||
|
||||
$renderContent = static function () use ($view, $renderSetup, $viewMeta): void {
|
||||
$currentViewMeta = $viewMeta[$view] ?? $viewMeta['overview'];
|
||||
$navItems = [
|
||||
'overview' => '/apps/boersenchecker/index.php?view=overview',
|
||||
'depotverwaltung' => '/apps/boersenchecker/index.php?view=depotverwaltung',
|
||||
'aktienverwaltung' => '/apps/boersenchecker/index.php?view=aktienverwaltung',
|
||||
'setup' => '/apps/boersenchecker/index.php?view=setup',
|
||||
];
|
||||
|
||||
ob_start();
|
||||
if ($view === 'setup') {
|
||||
$renderSetup();
|
||||
} elseif ($view === 'depotverwaltung') {
|
||||
$page = new \Modules\Boersenchecker\Support\DashboardPage();
|
||||
module_tpl('boersenchecker', 'dashboard', $page->handle());
|
||||
} elseif ($view === 'aktienverwaltung') {
|
||||
$page = new \Modules\Boersenchecker\Support\InstrumentPage();
|
||||
module_tpl('boersenchecker', 'instruments', $page->handle());
|
||||
} else {
|
||||
$page = new \Modules\Boersenchecker\Support\HomePage();
|
||||
module_tpl('boersenchecker', 'home', $page->handle());
|
||||
}
|
||||
$innerContent = (string) ob_get_clean();
|
||||
?>
|
||||
<div class="boersenchecker-module-shell window-app-shell">
|
||||
<div class="window-app-frame bc-frame">
|
||||
<aside class="window-app-sidebar bc-sidebar">
|
||||
<div class="window-app-brand bc-brand">
|
||||
<p class="window-app-kicker bc-kicker">Modul</p>
|
||||
<h1>Boersenchecker</h1>
|
||||
<p class="window-app-copy">Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung im Desktop-Standardlayout.</p>
|
||||
</div>
|
||||
<div class="window-app-nav-list bc-nav-list">
|
||||
<?php foreach ($navItems as $navView => $href): ?>
|
||||
<?php $meta = $viewMeta[$navView] ?? null; ?>
|
||||
<?php if (!$meta): continue; endif; ?>
|
||||
<a
|
||||
class="window-app-nav-button bc-nav-button<?= $view === $navView ? ' is-active' : '' ?>"
|
||||
href="<?= e($href) ?>"
|
||||
>
|
||||
<strong><?= e((string) $meta['label']) ?></strong>
|
||||
<span class="window-app-meta"><?= e((string) $meta['nav_description']) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="window-app-main bc-main">
|
||||
<div class="window-app-panel bc-panel-shell">
|
||||
<section class="window-app-hero bc-hero">
|
||||
<div>
|
||||
<p class="window-app-kicker bc-kicker">Boersenchecker</p>
|
||||
<h2 class="window-app-title bc-title"><?= e((string) $currentViewMeta['title']) ?></h2>
|
||||
<p class="window-app-copy"><?= e((string) $currentViewMeta['description']) ?></p>
|
||||
</div>
|
||||
<div class="window-app-pill-row bc-pill-row">
|
||||
<span class="window-app-pill bc-pill">Desktop-App</span>
|
||||
<span class="window-app-pill bc-pill">Legacy-Migration</span>
|
||||
<span class="window-app-pill bc-pill">View: <?= e((string) $currentViewMeta['label']) ?></span>
|
||||
</div>
|
||||
</section>
|
||||
<?= $innerContent ?>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
};
|
||||
|
||||
ob_start();
|
||||
$renderContent();
|
||||
$pageContent = (string) ob_get_clean();
|
||||
|
||||
if ($isPartial) {
|
||||
echo $pageContent;
|
||||
return;
|
||||
}
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Börsenchecker</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface: rgba(255, 255, 255, 0.92);
|
||||
--line: rgba(148, 163, 184, 0.24);
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--brand-accent: #14b8a6;
|
||||
--brand-accent-2: #0f766e;
|
||||
--brand-accent-3: #0f766e;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { min-height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
.boersenchecker-module-shell {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
.section-box,
|
||||
.submenu-box,
|
||||
.card-box {
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||
padding: 22px 24px;
|
||||
}
|
||||
.module-page-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.module-hero-copy { margin-bottom: 16px; }
|
||||
.module-title { margin: 0; font-size: 2.4rem; line-height: 1.05; }
|
||||
.module-lead { margin: 12px 0 0; color: var(--muted); line-height: 1.6; }
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: #4338ca;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.module-hero-top--compact,
|
||||
.module-tabs,
|
||||
.module-hero-actions { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
|
||||
.module-hero-top--compact { justify-content:space-between; gap:16px; }
|
||||
.module-button {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
min-height:44px;
|
||||
padding:10px 16px;
|
||||
border-radius:999px;
|
||||
text-decoration:none;
|
||||
border:1px solid rgba(148, 163, 184, 0.28);
|
||||
color:#0f172a;
|
||||
background:#ffffff;
|
||||
font-weight:700;
|
||||
}
|
||||
.module-button--tab-active,
|
||||
.module-button--primary {
|
||||
color:#ffffff;
|
||||
border-color:transparent;
|
||||
background:linear-gradient(135deg, var(--brand-accent), var(--brand-accent-3));
|
||||
}
|
||||
.module-button--secondary,
|
||||
.module-button--tab {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
.module-button--small { min-height:40px; padding:8px 14px; }
|
||||
.setup-field { display:grid; gap:8px; }
|
||||
.setup-field input, .setup-field select, .setup-field textarea {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
border-radius: 14px;
|
||||
font: inherit;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
}
|
||||
.setup-field textarea { min-height: 100px; }
|
||||
.grid { display:grid; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/assets/desktop/desktop.css">
|
||||
<link rel="stylesheet" href="/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker.css&v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.css'), ENT_QUOTES) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<?= $pageContent ?>
|
||||
|
||||
<script src="/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker.js&v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.js'), ENT_QUOTES) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
509
custom/apps/boersenchecker/partials/dashboard.php
Normal file
509
custom/apps/boersenchecker/partials/dashboard.php
Normal file
@@ -0,0 +1,509 @@
|
||||
<?php $ownerQuery = $isAdmin ? '&owner_sub=' . urlencode((string) $ownerSub) : ''; ?>
|
||||
<div class="bc-page">
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($isAdmin): ?>
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Benutzer-Scope</h2>
|
||||
<p>Depots anderer Benutzer sind nur fuer `appadmin` sichtbar und bearbeitbar.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" action="/apps/boersenchecker/index.php" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<input type="hidden" name="view" value="depotverwaltung">
|
||||
<label class="setup-field muted" style="margin:0; min-width:260px;">
|
||||
<span>Depots von Benutzer</span>
|
||||
<select name="owner_sub">
|
||||
<?php foreach ($availableOwners as $owner): ?>
|
||||
<option value="<?= e((string) $owner['sub']) ?>" <?= (string) $ownerSub === (string) $owner['sub'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $owner['label']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<button class="bc-button bc-button--primary" type="submit">Anzeigen</button>
|
||||
</form>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bc-card-grid">
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title"><?= $editPortfolio ? 'Depot bearbeiten' : 'Neues Depot' ?></h2>
|
||||
<p>Stammdaten und Berichtswahrung fuer ein Depot pflegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_portfolio">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) ($editPortfolio['id'] ?? '0')) ?>">
|
||||
<label class="setup-field muted">
|
||||
<span>Depotname</span>
|
||||
<input type="text" name="portfolio_name" value="<?= e((string) ($editPortfolio['name'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Berichtswahrung</span>
|
||||
<input type="text" name="portfolio_base_currency" value="<?= e((string) ($editPortfolio['base_currency'] ?? $defaultReportCurrency)) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Notizen</span>
|
||||
<textarea name="portfolio_notes" rows="3"><?= e((string) ($editPortfolio['notes'] ?? '')) ?></textarea>
|
||||
</label>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Depot speichern</button>
|
||||
<?php if ($editPortfolio): ?>
|
||||
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">API / FX</h2>
|
||||
<p>Kurs- und Waehrungsdaten zentral aktualisieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:16px;">
|
||||
Die Umrechnung liest gespeicherte FX-Daten zentral aus dem Modul fx-rates. Eine Aktualisierung wird nur manuell
|
||||
angestossen und respektiert die dortige Max-Age- und Reuse-Logik.
|
||||
</p>
|
||||
<p class="muted" style="margin-top:12px;">
|
||||
Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten.
|
||||
</p>
|
||||
<div class="bc-actions" style="margin-top:16px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="refresh_fx">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<button class="bc-button bc-button--primary" type="submit">FX-Daten aktualisieren</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="refresh_market_data_all">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Alle API-Kurse abrufen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:12px;">
|
||||
Alpha Vantage Mindestabstand: <?= e((string) $marketDataMinIntervalMinutes) ?> Min.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
API-Key und Timeout fuer Aktienkurse werden ueber <a href="/apps/boersenchecker/index.php?view=setup">dieses Modul-Setup</a> gepflegt.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
FX-Provider, API-Key und Waehrungskatalog werden im Modul <a href="/apps/fx-rates">fx-rates</a> gepflegt.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:12px;">
|
||||
Standard-Berichtswahrung: <?= e($defaultReportCurrency) ?> · Max. Alter: <?= e((string) $fxMaxAgeHours) ?>h
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title"><?= $editPosition ? 'Position bearbeiten' : 'Neue Position' ?></h2>
|
||||
<p>Aktienpositionen fuer ein Depot mit Kaufdaten und Kurswaehrung verwalten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($portfolios === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Bitte zuerst ein Depot anlegen.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_position">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="position_id" value="<?= e((string) ($editPosition['id'] ?? '0')) ?>">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($editPosition['instrument_id'] ?? '0')) ?>">
|
||||
<label class="setup-field muted">
|
||||
<span>Depot</span>
|
||||
<select name="portfolio_id" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($portfolios as $portfolio): ?>
|
||||
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) ($editPosition['portfolio_id'] ?? '') === (string) $portfolio['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $portfolio['name']) ?> (<?= e((string) $portfolio['base_currency']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Aktienname</span>
|
||||
<input type="text" name="instrument_name" value="<?= e((string) ($editPosition['instrument_name'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>API-Symbol / Ticker</span>
|
||||
<input type="text" name="symbol" value="<?= e((string) ($editPosition['symbol'] ?? '')) ?>" placeholder="z.B. AAPL oder MBG.DE">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>ISIN</span>
|
||||
<input type="text" name="isin" value="<?= e((string) ($editPosition['isin'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>WKN</span>
|
||||
<input type="text" name="wkn" value="<?= e((string) ($editPosition['wkn'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Boerse / Markt</span>
|
||||
<input type="text" name="market" value="<?= e((string) ($editPosition['market'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kurswaehrung</span>
|
||||
<input type="text" name="quote_currency" value="<?= e((string) ($editPosition['quote_currency'] ?? $defaultReportCurrency)) ?>" required>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Stueckzahl</span>
|
||||
<input type="number" name="quantity" min="0" step="0.000001" value="<?= e((string) ($editPosition['quantity'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kaufpreis</span>
|
||||
<input type="number" name="purchase_price" min="0" step="0.00000001" value="<?= e((string) ($editPosition['purchase_price'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kaufwaehrung</span>
|
||||
<input type="text" name="purchase_currency" value="<?= e((string) ($editPosition['purchase_currency'] ?? $defaultReportCurrency)) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kaufdatum</span>
|
||||
<input type="date" name="purchase_date" value="<?= e((string) ($editPosition['purchase_date'] ?? date('Y-m-d'))) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Gebuehren</span>
|
||||
<input type="number" name="fees" min="0" step="0.00000001" value="<?= e((string) ($editPosition['fees'] ?? '')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
<label class="setup-field muted">
|
||||
<span>Notizen</span>
|
||||
<textarea name="position_notes" rows="3"><?= e((string) ($editPosition['notes'] ?? '')) ?></textarea>
|
||||
</label>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Position speichern</button>
|
||||
<?php if ($editPosition): ?>
|
||||
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Wertpapiersuche</h2>
|
||||
<p>Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-section-copy">
|
||||
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<input type="hidden" name="action" value="search_symbol">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
|
||||
<span>Suchbegriff</span>
|
||||
<input type="text" name="search_keywords" value="<?= e($symbolSearchKeywords) ?>" placeholder="z.B. Mercedes, AAPL, Allianz" required>
|
||||
</label>
|
||||
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php if ($symbolSearchResults !== []): ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Name</th>
|
||||
<th>Typ</th>
|
||||
<th>Region</th>
|
||||
<th>Waehrung</th>
|
||||
<th>Match</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($symbolSearchResults as $result): ?>
|
||||
<tr>
|
||||
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
|
||||
<td><?= e((string) ($result['name'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['type'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['region'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['match_score'] ?? '')) ?></td>
|
||||
<td>
|
||||
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>"e_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
|
||||
In Formular uebernehmen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Manuellen Kurs erfassen</h2>
|
||||
<p>Kurse mit Uhrzeit und Quelle direkt in die Historie schreiben.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($instrumentList === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_quote">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Aktie</span>
|
||||
<select name="quote_instrument_id" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($instrumentList as $instrument): ?>
|
||||
<option value="<?= e((string) $instrument['id']) ?>" <?= $selectedInstrumentForQuote === (int) $instrument['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $instrument['name']) ?><?= $instrument['symbol'] !== '' ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kurs</span>
|
||||
<input type="number" name="quote_price" min="0" step="0.00000001" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Waehrung</span>
|
||||
<input type="text" name="quote_currency" value="<?= e($selectedInstrumentQuoteCurrency) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Zeitpunkt</span>
|
||||
<input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Quelle</span>
|
||||
<input type="text" name="quote_source" value="manual">
|
||||
</label>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Depots</h2>
|
||||
<p>Uebersicht aller Depots mit Kennzahlen und Schnellaktionen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($portfolios === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Noch keine Depots vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-card-grid" style="margin-top:16px;">
|
||||
<?php foreach ($portfolios as $portfolio): ?>
|
||||
<?php
|
||||
$portfolioId = (int) $portfolio['id'];
|
||||
$stats = $portfolioStats[$portfolioId] ?? ['positions' => 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false];
|
||||
?>
|
||||
<section class="card-box">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong><?= e((string) $portfolio['name']) ?></strong>
|
||||
<div class="muted"><?= e((string) $portfolio['base_currency']) ?> · <?= e((string) $stats['positions']) ?> Position(en)</div>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_portfolio=<?= e((string) $portfolioId) ?>">Bearbeiten</a>
|
||||
<form method="post" onsubmit="return confirm('Depot wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_portfolio">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) $portfolioId) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!empty($portfolio['notes'])): ?>
|
||||
<div class="muted" style="margin-top:10px;"><?= e((string) $portfolio['notes']) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="grid" style="margin-top:16px; grid-template-columns:repeat(auto-fit, minmax(140px, 1fr)); gap:10px;">
|
||||
<div class="bc-stat">
|
||||
<div class="muted">Investiert</div>
|
||||
<strong><?= $stats['has_invested'] ? e($fmtNumber((float) $stats['invested'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
|
||||
</div>
|
||||
<div class="bc-stat">
|
||||
<div class="muted">Aktuell</div>
|
||||
<strong><?= $stats['has_current'] ? e($fmtNumber((float) $stats['current'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
|
||||
</div>
|
||||
<div class="bc-stat">
|
||||
<div class="muted">Gewinn / Verlust</div>
|
||||
<strong><?= $stats['gain'] !== null ? e($fmtNumber((float) $stats['gain'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Positionen</h2>
|
||||
<p>Alle Positionen mit Kaufdaten, letztem Kurs und aktuellen Werten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($positions === []): ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Noch keine Positionen vorhanden.</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Depot</th>
|
||||
<th>Aktie</th>
|
||||
<th>ISIN / WKN</th>
|
||||
<th>Stueck</th>
|
||||
<th>Kauf</th>
|
||||
<th>Letzter Kurs</th>
|
||||
<th>Wert</th>
|
||||
<th>Delta</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($positions as $position): ?>
|
||||
<tr>
|
||||
<td><?= e((string) ($portfolioById[(int) $position['portfolio_id']]['name'] ?? '')) ?></td>
|
||||
<td>
|
||||
<strong><?= e((string) $position['instrument_name']) ?></strong>
|
||||
<?php if (!empty($position['symbol'])): ?>
|
||||
<div class="muted"><?= e((string) $position['symbol']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string) ($position['isin'] ?: '-')) ?>
|
||||
<div class="muted"><?= e((string) ($position['wkn'] ?: '-')) ?></div>
|
||||
</td>
|
||||
<td><?= e($fmtNumber((float) $position['quantity'], 6)) ?></td>
|
||||
<td>
|
||||
<?= e($fmtNumber((float) $position['purchase_price'], 4)) ?> <?= e((string) $position['purchase_currency']) ?>
|
||||
<div class="muted"><?= e((string) $position['purchase_date']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($position['latest_price'] !== null): ?>
|
||||
<?= e($fmtNumber((float) $position['latest_price'], 4)) ?> <?= e((string) $position['latest_currency']) ?>
|
||||
<div class="muted"><?= e($fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? ''))) ?></div>
|
||||
<?php else: ?>
|
||||
<span class="muted">kein Kurs</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($position['current_total_base'] !== null): ?>
|
||||
<?= e($fmtNumber((float) $position['current_total_base'])) ?> <?= e((string) $position['base_currency']) ?>
|
||||
<?php else: ?>
|
||||
<span class="muted">n/a</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($position['gain_base'] !== null): ?>
|
||||
<?= e($fmtNumber((float) $position['gain_base'])) ?> <?= e((string) $position['base_currency']) ?>
|
||||
<?php else: ?>
|
||||
<span class="muted">n/a</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="bc-actions">
|
||||
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_position=<?= e((string) $position['id']) ?>">Bearbeiten</a>
|
||||
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $position['instrument_id']) ?>">Kurs erfassen</a>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="refresh_market_data_position">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">API-Kurs</button>
|
||||
</form>
|
||||
<form method="post" onsubmit="return confirm('Position wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_position">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Kursverlauf</h2>
|
||||
<p>Historische Kurse pro Aktie mit Zeitstempel und Quelle.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($instrumentList === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Noch keine Kursdaten vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-card-grid" style="margin-top:16px;">
|
||||
<?php foreach ($instrumentList as $instrumentId => $instrument): ?>
|
||||
<?php $history = array_slice($quoteHistory[$instrumentId] ?? [], 0, 10); ?>
|
||||
<section class="card-box">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong><?= e((string) $instrument['name']) ?></strong>
|
||||
<div class="muted">
|
||||
<?= e((string) ($instrument['symbol'] ?: '-')) ?> · <?= e((string) ($instrument['isin'] ?: '-')) ?>
|
||||
</div>
|
||||
</div>
|
||||
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $instrumentId) ?>">Neuen Kurs erfassen</a>
|
||||
</div>
|
||||
<?php if ($history === []): ?>
|
||||
<div class="muted" style="margin-top:12px;">Noch keine historischen Kurse vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-table-shell" style="margin-top:12px;">
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Kurs</th>
|
||||
<th>Quelle</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($history as $quote): ?>
|
||||
<tr>
|
||||
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
|
||||
<td><?= e($fmtNumber((float) $quote['price'], 4)) ?> <?= e((string) $quote['currency']) ?></td>
|
||||
<td><?= e((string) $quote['source']) ?></td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_quote">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
207
custom/apps/boersenchecker/partials/home.php
Normal file
207
custom/apps/boersenchecker/partials/home.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<div class="bc-page" data-bc-home data-chart-endpoint="/api/boersenchecker/index.php?path=v1/chart-data">
|
||||
<script type="application/json" data-bc-instruments-json><?= json_encode(array_map(static function (array $position): array {
|
||||
return [
|
||||
'instrument_id' => (int) ($position['instrument_id'] ?? 0),
|
||||
'instrument_name' => (string) ($position['instrument_name'] ?? ''),
|
||||
'symbol' => (string) ($position['symbol'] ?? ''),
|
||||
'isin' => (string) ($position['isin'] ?? ''),
|
||||
];
|
||||
}, $positions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Marktueberblick</h2>
|
||||
<p>Depotauswahl, Aktienfokus und aktueller Kursabruf in einem Bereich.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-toolbar" style="margin-top:16px;">
|
||||
<form class="bc-panel" method="get" action="/apps/boersenchecker/index.php">
|
||||
<input type="hidden" name="view" value="overview">
|
||||
<div class="bc-field-label">Depotauswahl</div>
|
||||
<?php if ($portfolios === []): ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Keine Depots vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<label class="setup-field" style="margin-top:12px;">
|
||||
<span class="bc-text">Depot</span>
|
||||
<select name="portfolio_id" onchange="this.form.submit()">
|
||||
<?php foreach ($portfolios as $portfolio): ?>
|
||||
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) $selectedPortfolioId === (string) $portfolio['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $portfolio['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<form class="bc-panel" method="get" action="/apps/boersenchecker/index.php">
|
||||
<input type="hidden" name="view" value="overview">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
|
||||
<div class="bc-field-label">Aktienauswahl</div>
|
||||
<?php if ($positions === []): ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Keine Aktien im ausgewaehlten Depot.</div>
|
||||
<?php else: ?>
|
||||
<label class="setup-field" style="margin-top:12px;">
|
||||
<span class="bc-text">Aktie</span>
|
||||
<select name="instrument_id" data-bc-instrument onchange="this.form.submit()">
|
||||
<?php foreach ($positions as $position): ?>
|
||||
<option value="<?= e((string) $position['instrument_id']) ?>" <?= (string) $selectedInstrumentId === (string) $position['instrument_id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $position['instrument_name']) ?><?= !empty($position['symbol']) ? ' (' . e((string) $position['symbol']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<form class="bc-panel" method="post">
|
||||
<input type="hidden" name="action" value="refresh_current_quotes_home">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
|
||||
<div class="bc-field-label">Marktdaten</div>
|
||||
<p class="bc-text" style="margin-top:12px;">Aktuelle Kurse fuer das gewaehlte Depot ueber Alpha Vantage anhand des hinterlegten Symbols abrufen.</p>
|
||||
<div class="bc-actions" style="margin-top:16px;">
|
||||
<button class="bc-button bc-button--primary" type="submit" <?= $selectedPortfolioId > 0 ? '' : 'disabled' ?>>Aktuelle Kurse abrufen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="bc-overview-grid">
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Positionen</div>
|
||||
<div class="bc-stat-value"><?= e((string) ($summary['positions'] ?? 0)) ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;">Aktien im aktuell gewaehlten Depot</div>
|
||||
</section>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Investiert</div>
|
||||
<div class="bc-stat-value"><?= isset($summary['invested']) && $summary['invested'] !== null ? e(number_format((float) $summary['invested'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;">In Berichtswahrung bewertet</div>
|
||||
</section>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Aktueller Wert</div>
|
||||
<div class="bc-stat-value"><?= isset($summary['current']) && $summary['current'] !== null ? e(number_format((float) $summary['current'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;">Basierend auf dem letzten gespeicherten Kurs</div>
|
||||
</section>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Performance</div>
|
||||
<div class="bc-stat-value"><?= isset($summary['gain']) && $summary['gain'] !== null ? e(number_format((float) $summary['gain'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;"><?= !empty($summary['best']['instrument_name']) ? 'Top-Wert: ' . e((string) $summary['best']['instrument_name']) : 'Noch keine Vergleichsdaten' ?></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="bc-card-grid">
|
||||
<section class="card-box">
|
||||
<div class="bc-field-label">Bester Wert</div>
|
||||
<?php if (!empty($summary['best'])): ?>
|
||||
<div class="bc-stat-value"><?= e((string) $summary['best']['instrument_name']) ?></div>
|
||||
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['best']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card-box">
|
||||
<div class="bc-field-label">Schwaechster Wert</div>
|
||||
<?php if (!empty($summary['worst'])): ?>
|
||||
<div class="bc-stat-value"><?= e((string) $summary['worst']['instrument_name']) ?></div>
|
||||
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['worst']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php foreach (array_slice($positions, 0, 2) as $position): ?>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label"><?= e((string) $position['instrument_name']) ?></div>
|
||||
<div class="bc-stat-value"><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;"><?= e((string) (($position['latest_quoted_at'] ?? '') !== '' ? $fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? '')) : 'kein Kurs')) ?></div>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Kursverlauf</h2>
|
||||
<p>Schlusskurse ueber mehrere Zeitfenster fuer das aktuell gewaehlte Instrument.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-chart-card" style="margin-top:16px;">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">
|
||||
<div>
|
||||
<div class="bc-field-label">Aktie</div>
|
||||
<div class="bc-stat-value" data-bc-instrument-name><?= e((string) ($selectedInstrument['instrument_name'] ?? 'Keine Aktie ausgewaehlt')) ?></div>
|
||||
<?php if ($selectedInstrument): ?>
|
||||
<div class="bc-text" data-bc-instrument-meta><?= e((string) ($selectedInstrument['symbol'] ?? '')) ?> · <?= e((string) ($selectedInstrument['isin'] ?? '-')) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="bc-range-list">
|
||||
<button type="button" class="bc-range-button" data-range="1d" aria-pressed="false">Tag</button>
|
||||
<button type="button" class="bc-range-button" data-range="5d" aria-pressed="false">5 Tage</button>
|
||||
<button type="button" class="bc-range-button" data-range="1m" aria-pressed="true">Monat</button>
|
||||
<button type="button" class="bc-range-button" data-range="3m" aria-pressed="false">3 Monate</button>
|
||||
<button type="button" class="bc-range-button" data-range="6m" aria-pressed="false">6 Monate</button>
|
||||
<button type="button" class="bc-range-button" data-range="1y" aria-pressed="false">Jahr</button>
|
||||
<button type="button" class="bc-range-button" data-range="5y" aria-pressed="false">5 Jahre</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-text" data-bc-chart-status style="margin-top:12px;">Chartdaten werden geladen...</div>
|
||||
<div class="bc-stat-value" data-bc-chart-summary style="margin-top:6px;">-</div>
|
||||
<div class="bc-chart-shell" data-bc-chart style="margin-top:18px;"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Aktien im Depot</h2>
|
||||
<p>Stueckzahl, Kaufdaten, letzter Kurs und Performance auf einen Blick.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-section-copy">
|
||||
<?php if ($positions === []): ?>
|
||||
<div class="bc-text" style="padding:0 0 18px;">Keine Aktien im ausgewaehlten Depot.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-position-list" style="padding:0 0 18px;">
|
||||
<?php foreach ($positions as $position): ?>
|
||||
<?php $gainClass = (($position['gain_report'] ?? 0) >= 0) ? 'is-positive' : 'is-negative'; ?>
|
||||
<div class="bc-position-row">
|
||||
<div>
|
||||
<strong><?= e((string) $position['instrument_name']) ?></strong>
|
||||
<div class="bc-text" style="margin-top:4px;"><?= e((string) ($position['symbol'] ?? '')) ?> · <?= e((string) ($position['isin'] ?? '-')) ?></div>
|
||||
<?php if (!empty($position['market'])): ?>
|
||||
<div class="bc-pill-soft" style="margin-top:10px;"><?= e((string) $position['market']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Stueckzahl</div>
|
||||
<div><?= e(number_format((float) $position['quantity'], 6, ',', '.')) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Kaufpreis</div>
|
||||
<div><?= e(number_format((float) $position['purchase_price'], 2, ',', '.')) ?> <?= e((string) $position['purchase_currency']) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Letzter Kurs</div>
|
||||
<div><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Performance</div>
|
||||
<div class="bc-performance <?= e($gainClass) ?>">
|
||||
<?= isset($position['gain_report']) && $position['gain_report'] !== null ? e(number_format((float) $position['gain_report'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
184
custom/apps/boersenchecker/partials/instruments.php
Normal file
184
custom/apps/boersenchecker/partials/instruments.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<div class="bc-page">
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bc-card-grid">
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Aktie waehlen</h2>
|
||||
<p>Systemweit vorhandene Aktie aus allen Depots auswaehlen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" action="/apps/boersenchecker/index.php" style="margin-top:16px;">
|
||||
<input type="hidden" name="view" value="aktienverwaltung">
|
||||
<label class="setup-field muted">
|
||||
<span>Aktien aller Depots</span>
|
||||
<select name="instrument_id" onchange="this.form.submit()">
|
||||
<?php foreach ($instruments as $instrument): ?>
|
||||
<option value="<?= e((string) $instrument['id']) ?>" <?= (string) $selectedInstrumentId === (string) $instrument['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $instrument['name']) ?><?= !empty($instrument['symbol']) ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Wertpapiersuche</h2>
|
||||
<p>Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-section-copy">
|
||||
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<input type="hidden" name="action" value="search_symbol">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
|
||||
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
|
||||
<span>Suchbegriff</span>
|
||||
<input type="text" name="search_keywords" value="<?= e($searchKeywords) ?>" placeholder="z.B. Apple, AAPL, Allianz" required>
|
||||
</label>
|
||||
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php if ($searchResults !== []): ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Name</th>
|
||||
<th>Region</th>
|
||||
<th>Waehrung</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($searchResults as $result): ?>
|
||||
<tr>
|
||||
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
|
||||
<td><?= e((string) ($result['name'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['region'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
|
||||
<td>
|
||||
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=aktienverwaltung&instrument_id=<?= e((string) $selectedInstrumentId) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>"e_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
|
||||
Uebernehmen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Aktie bearbeiten</h2>
|
||||
<p>Stammdaten, Markt und Kurswaehrung zentral fuer die Aktie pflegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
|
||||
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_instrument">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted"><span>Name</span><input type="text" name="instrument_name" value="<?= e((string) (($selectedInstrument['name'] ?? '') ?: ($_GET['instrument_name_candidate'] ?? ''))) ?>" required></label>
|
||||
<label class="setup-field muted"><span>Symbol</span><input type="text" name="symbol" value="<?= e((string) (($selectedInstrument['symbol'] ?? '') ?: ($_GET['symbol_candidate'] ?? ''))) ?>"></label>
|
||||
<label class="setup-field muted"><span>ISIN</span><input type="text" name="isin" value="<?= e((string) $selectedInstrument['isin'] ?? '') ?>"></label>
|
||||
<label class="setup-field muted"><span>WKN</span><input type="text" name="wkn" value="<?= e((string) $selectedInstrument['wkn'] ?? '') ?>"></label>
|
||||
<label class="setup-field muted"><span>Markt</span><input type="text" name="market" value="<?= e((string) (($selectedInstrument['market'] ?? '') ?: ($_GET['market_candidate'] ?? ''))) ?>"></label>
|
||||
<label class="setup-field muted"><span>Kurswaehrung</span><input type="text" name="quote_currency" value="<?= e((string) (($selectedInstrument['quote_currency'] ?? $defaultReportCurrency) ?: ($_GET['quote_currency_candidate'] ?? $defaultReportCurrency))) ?>"></label>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Aktie speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" style="margin-top:12px;">
|
||||
<input type="hidden" name="action" value="refresh_market_data_instrument">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Aktuellen API-Kurs abrufen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Manuellen Kurs eingeben</h2>
|
||||
<p>Einzelne Kurse mit Zeitstempel und Quelle fuer die ausgewaehlte Aktie speichern.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
|
||||
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_quote">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted"><span>Kurs</span><input type="number" name="quote_price" min="0" step="0.00000001" required></label>
|
||||
<label class="setup-field muted"><span>Waehrung</span><input type="text" name="quote_currency" value="<?= e((string) ($selectedInstrument['quote_currency'] ?? $defaultReportCurrency)) ?>" required></label>
|
||||
<label class="setup-field muted"><span>Zeitpunkt</span><input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required></label>
|
||||
<label class="setup-field muted"><span>Quelle</span><input type="text" name="quote_source" value="manual"></label>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Kursverlauf</h2>
|
||||
<p>Gespeicherte Kursdaten der ausgewaehlten Aktie mit Quelle und Loeschoption.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($quotes === []): ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Keine Kursdaten vorhanden.</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Kurs</th>
|
||||
<th>Quelle</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($quotes as $quote): ?>
|
||||
<tr>
|
||||
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
|
||||
<td><?= e(number_format((float) $quote['price'], 4, ',', '.')) ?> <?= e((string) $quote['currency']) ?></td>
|
||||
<td><?= e((string) $quote['source']) ?></td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_quote">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
|
||||
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
898
custom/apps/boersenchecker/src/Support/DashboardPage.php
Normal file
898
custom/apps/boersenchecker/src/Support/DashboardPage.php
Normal file
@@ -0,0 +1,898 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class DashboardPage
|
||||
{
|
||||
private PDO $pdo;
|
||||
private array $user;
|
||||
private bool $isAdmin;
|
||||
private string $ownerSub;
|
||||
private array $moduleSettings;
|
||||
private string $defaultReportCurrency;
|
||||
private float $fxMaxAgeHours;
|
||||
private int $marketDataMinIntervalMinutes;
|
||||
private int $editPortfolioId;
|
||||
private int $editPositionId;
|
||||
private string $portfolioTable;
|
||||
private string $instrumentTable;
|
||||
private string $positionTable;
|
||||
private string $quoteTable;
|
||||
private InstrumentRegistry $instrumentRegistry;
|
||||
private string $symbolSearchKeywords = '';
|
||||
private array $symbolSearchResults = [];
|
||||
private array $availableOwners = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = \module_fn('boersenchecker', 'pdo');
|
||||
\module_fn('boersenchecker', 'ensure_schema');
|
||||
|
||||
$this->user = \auth_user() ?? [];
|
||||
$this->isAdmin = \auth_is_admin();
|
||||
$this->ownerSub = trim((string) ($this->user['sub'] ?? 'local'));
|
||||
$this->availableOwners = $this->buildAvailableOwners();
|
||||
if ($this->isAdmin) {
|
||||
$requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? ''));
|
||||
if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) {
|
||||
$this->ownerSub = $requestedOwner;
|
||||
}
|
||||
}
|
||||
$this->moduleSettings = \modules()->settings('boersenchecker');
|
||||
$this->defaultReportCurrency = $this->normalizeCurrency((string) ($this->moduleSettings['report_currency'] ?? 'EUR'));
|
||||
$this->fxMaxAgeHours = (float) ($this->moduleSettings['fx_max_age_hours'] ?? 6);
|
||||
if ($this->fxMaxAgeHours <= 0) {
|
||||
$this->fxMaxAgeHours = 6.0;
|
||||
}
|
||||
|
||||
$this->marketDataMinIntervalMinutes = (int) (($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
|
||||
if ($this->marketDataMinIntervalMinutes <= 0) {
|
||||
$this->marketDataMinIntervalMinutes = 60;
|
||||
}
|
||||
|
||||
$this->editPortfolioId = (int) ($_GET['edit_portfolio'] ?? 0);
|
||||
$this->editPositionId = (int) ($_GET['edit_position'] ?? 0);
|
||||
|
||||
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
|
||||
$this->portfolioTable = $table('portfolios');
|
||||
$this->instrumentTable = $table('instruments');
|
||||
$this->positionTable = $table('positions');
|
||||
$this->quoteTable = $table('quotes');
|
||||
$this->instrumentRegistry = new InstrumentRegistry(
|
||||
$this->pdo,
|
||||
$this->instrumentTable,
|
||||
$this->positionTable,
|
||||
$this->quoteTable,
|
||||
);
|
||||
}
|
||||
|
||||
public function handle(): array
|
||||
{
|
||||
$notice = null;
|
||||
$error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$notice = $this->handlePost();
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$state = $this->loadState();
|
||||
|
||||
if ($notice === null && isset($state['notice_override']) && is_string($state['notice_override'])) {
|
||||
$notice = $state['notice_override'];
|
||||
}
|
||||
if ($error === null && isset($state['error_override']) && is_string($state['error_override'])) {
|
||||
$error = $state['error_override'];
|
||||
}
|
||||
|
||||
return [
|
||||
'notice' => $notice,
|
||||
'error' => $error,
|
||||
'isAdmin' => $this->isAdmin,
|
||||
'ownerSub' => $this->ownerSub,
|
||||
'availableOwners' => array_values($this->availableOwners),
|
||||
'defaultReportCurrency' => $this->defaultReportCurrency,
|
||||
'fxMaxAgeHours' => $this->fxMaxAgeHours,
|
||||
'marketDataMinIntervalMinutes' => $this->marketDataMinIntervalMinutes,
|
||||
'symbolSearchKeywords' => $this->symbolSearchKeywords,
|
||||
'symbolSearchResults' => $this->symbolSearchResults,
|
||||
'editPortfolio' => $state['editPortfolio'],
|
||||
'editPosition' => $state['editPosition'],
|
||||
'portfolios' => $state['portfolios'],
|
||||
'portfolioById' => $state['portfolioById'],
|
||||
'portfolioStats' => $state['portfolioStats'],
|
||||
'positions' => $state['positions'],
|
||||
'instrumentList' => $state['instrumentList'],
|
||||
'quoteHistory' => $state['quoteHistory'],
|
||||
'selectedInstrumentForQuote' => $state['selectedInstrumentForQuote'],
|
||||
'selectedInstrumentQuoteCurrency' => $state['selectedInstrumentQuoteCurrency'],
|
||||
'fmtNumber' => fn (?float $value, int $scale = 2): string => $this->formatNumber($value, $scale),
|
||||
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
|
||||
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
|
||||
];
|
||||
}
|
||||
|
||||
private function handlePost(): string
|
||||
{
|
||||
$action = trim((string) ($_POST['action'] ?? ''));
|
||||
|
||||
return match ($action) {
|
||||
'save_portfolio' => $this->savePortfolio(),
|
||||
'delete_portfolio' => $this->deletePortfolio(),
|
||||
'save_position' => $this->savePosition(),
|
||||
'delete_position' => $this->deletePosition(),
|
||||
'save_quote' => $this->saveQuote(),
|
||||
'refresh_market_data_position' => $this->refreshMarketDataPosition(),
|
||||
'refresh_market_data_all' => $this->refreshMarketDataAll(),
|
||||
'search_symbol' => $this->searchSymbol(),
|
||||
'delete_quote' => $this->deleteQuote(),
|
||||
'refresh_fx' => $this->refreshFx(),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function loadState(): array
|
||||
{
|
||||
$portfolios = $this->fetchPortfolios();
|
||||
$positions = $this->fetchPositions();
|
||||
$instrumentList = $this->buildInstrumentList($positions);
|
||||
[$quoteHistory, $latestQuotes] = $this->fetchQuotes(array_keys($instrumentList));
|
||||
$portfolioById = $this->buildPortfolioById($portfolios);
|
||||
[$positions, $portfolioStats] = $this->enrichPositions($positions, $portfolioById, $latestQuotes);
|
||||
|
||||
$editPortfolio = null;
|
||||
if ($this->editPortfolioId > 0 && isset($portfolioById[$this->editPortfolioId])) {
|
||||
$editPortfolio = $portfolioById[$this->editPortfolioId];
|
||||
}
|
||||
|
||||
$editPosition = null;
|
||||
if ($this->editPositionId > 0) {
|
||||
foreach ($positions as $position) {
|
||||
if ((int) $position['id'] === $this->editPositionId) {
|
||||
$editPosition = $position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$selectedInstrumentForQuote = $editPosition
|
||||
? (int) $editPosition['instrument_id']
|
||||
: (int) ($_GET['instrument_id'] ?? 0);
|
||||
$selectedInstrumentQuoteCurrency = $this->defaultReportCurrency;
|
||||
if ($selectedInstrumentForQuote > 0 && isset($instrumentList[$selectedInstrumentForQuote])) {
|
||||
$selectedInstrumentQuoteCurrency = $this->normalizeCurrency(
|
||||
(string) ($instrumentList[$selectedInstrumentForQuote]['quote_currency'] ?? $this->defaultReportCurrency)
|
||||
);
|
||||
}
|
||||
|
||||
$candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
|
||||
$candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
|
||||
$candidateIsin = trim((string) ($_GET['isin_candidate'] ?? ''));
|
||||
$candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
|
||||
$candidateCurrency = $this->normalizeCurrency((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency));
|
||||
|
||||
if ($editPosition === null) {
|
||||
$editPosition = [
|
||||
'instrument_name' => $candidateName,
|
||||
'symbol' => $candidateSymbol,
|
||||
'isin' => $candidateIsin,
|
||||
'market' => $candidateMarket,
|
||||
'quote_currency' => $candidateCurrency,
|
||||
'purchase_currency' => $this->defaultReportCurrency,
|
||||
'purchase_date' => date('Y-m-d'),
|
||||
];
|
||||
} else {
|
||||
if ($candidateName !== '') {
|
||||
$editPosition['instrument_name'] = $candidateName;
|
||||
}
|
||||
if ($candidateSymbol !== '') {
|
||||
$editPosition['symbol'] = $candidateSymbol;
|
||||
}
|
||||
if ($candidateIsin !== '') {
|
||||
$editPosition['isin'] = $candidateIsin;
|
||||
}
|
||||
if ($candidateMarket !== '') {
|
||||
$editPosition['market'] = $candidateMarket;
|
||||
}
|
||||
if ($candidateCurrency !== '') {
|
||||
$editPosition['quote_currency'] = $candidateCurrency;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'portfolios' => $portfolios,
|
||||
'portfolioById' => $portfolioById,
|
||||
'portfolioStats' => $portfolioStats,
|
||||
'positions' => $positions,
|
||||
'instrumentList' => $instrumentList,
|
||||
'quoteHistory' => $quoteHistory,
|
||||
'editPortfolio' => $editPortfolio,
|
||||
'editPosition' => $editPosition,
|
||||
'selectedInstrumentForQuote' => $selectedInstrumentForQuote,
|
||||
'selectedInstrumentQuoteCurrency' => $selectedInstrumentQuoteCurrency,
|
||||
];
|
||||
}
|
||||
|
||||
private function savePortfolio(): string
|
||||
{
|
||||
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
|
||||
$name = trim((string) ($_POST['portfolio_name'] ?? ''));
|
||||
$baseCurrency = $this->normalizeCurrency((string) ($_POST['portfolio_base_currency'] ?? $this->defaultReportCurrency));
|
||||
$notes = trim((string) ($_POST['portfolio_notes'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Bitte einen Depotnamen angeben.');
|
||||
}
|
||||
|
||||
if ($portfolioId > 0) {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->portfolioTable . '
|
||||
SET name = :name, base_currency = :base_currency, notes = :notes, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND owner_sub = :owner_sub'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'name' => $name,
|
||||
'base_currency' => $baseCurrency,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
]);
|
||||
return 'Depot aktualisiert.';
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->portfolioTable . ' (owner_sub, name, base_currency, notes)
|
||||
VALUES (:owner_sub, :name, :base_currency, :notes)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'name' => $name,
|
||||
'base_currency' => $baseCurrency,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
]);
|
||||
return 'Depot angelegt.';
|
||||
}
|
||||
|
||||
private function deletePortfolio(): string
|
||||
{
|
||||
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
|
||||
$countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM ' . $this->positionTable . ' WHERE portfolio_id = :portfolio_id AND owner_sub = :owner_sub');
|
||||
$countStmt->execute([
|
||||
'portfolio_id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
if ((int) $countStmt->fetchColumn() > 0) {
|
||||
throw new RuntimeException('Depot kann erst geloescht werden, wenn alle Positionen entfernt wurden.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub');
|
||||
$stmt->execute([
|
||||
'id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
return 'Depot geloescht.';
|
||||
}
|
||||
|
||||
private function savePosition(): string
|
||||
{
|
||||
$positionId = (int) ($_POST['position_id'] ?? 0);
|
||||
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
|
||||
$quantity = (float) ($_POST['quantity'] ?? 0);
|
||||
$purchasePrice = (float) ($_POST['purchase_price'] ?? 0);
|
||||
$purchaseCurrency = $this->normalizeCurrency((string) ($_POST['purchase_currency'] ?? $this->defaultReportCurrency));
|
||||
$purchaseDate = trim((string) ($_POST['purchase_date'] ?? ''));
|
||||
$fees = trim((string) ($_POST['fees'] ?? ''));
|
||||
$notes = trim((string) ($_POST['position_notes'] ?? ''));
|
||||
|
||||
if ($portfolioId <= 0) {
|
||||
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
|
||||
}
|
||||
if ($quantity <= 0 || $purchasePrice <= 0 || $purchaseDate === '') {
|
||||
throw new RuntimeException('Bitte Stueckzahl, Kaufpreis und Kaufdatum angeben.');
|
||||
}
|
||||
|
||||
$portfolioOwnerStmt = $this->pdo->prepare('SELECT id FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1');
|
||||
$portfolioOwnerStmt->execute([
|
||||
'id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
if ($portfolioOwnerStmt->fetchColumn() === false) {
|
||||
throw new RuntimeException('Das ausgewaehlte Depot ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
$instrumentId = $this->upsertInstrument([
|
||||
'id' => (int) ($_POST['instrument_id'] ?? 0),
|
||||
'isin' => $_POST['isin'] ?? '',
|
||||
'wkn' => $_POST['wkn'] ?? '',
|
||||
'symbol' => $_POST['symbol'] ?? '',
|
||||
'name' => $_POST['instrument_name'] ?? '',
|
||||
'quote_currency' => $_POST['quote_currency'] ?? $purchaseCurrency,
|
||||
'market' => $_POST['market'] ?? '',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'portfolio_id' => $portfolioId,
|
||||
'instrument_id' => $instrumentId,
|
||||
'quantity' => $quantity,
|
||||
'purchase_price' => $purchasePrice,
|
||||
'purchase_currency' => $purchaseCurrency,
|
||||
'purchase_date' => $purchaseDate,
|
||||
'fees' => $fees !== '' ? (float) $fees : null,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
];
|
||||
|
||||
if ($positionId > 0) {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->positionTable . '
|
||||
SET portfolio_id = :portfolio_id,
|
||||
instrument_id = :instrument_id,
|
||||
quantity = :quantity,
|
||||
purchase_price = :purchase_price,
|
||||
purchase_currency = :purchase_currency,
|
||||
purchase_date = :purchase_date,
|
||||
fees = :fees,
|
||||
notes = :notes,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND owner_sub = :owner_sub'
|
||||
);
|
||||
$stmt->execute($payload + ['id' => $positionId]);
|
||||
return 'Position aktualisiert.';
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->positionTable . ' (
|
||||
owner_sub, portfolio_id, instrument_id, quantity, purchase_price, purchase_currency, purchase_date, fees, notes
|
||||
) VALUES (
|
||||
:owner_sub, :portfolio_id, :instrument_id, :quantity, :purchase_price, :purchase_currency, :purchase_date, :fees, :notes
|
||||
)'
|
||||
);
|
||||
$stmt->execute($payload);
|
||||
return 'Position gespeichert.';
|
||||
}
|
||||
|
||||
private function deletePosition(): string
|
||||
{
|
||||
$positionId = (int) ($_POST['position_id'] ?? 0);
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->positionTable . ' WHERE id = :id AND owner_sub = :owner_sub');
|
||||
$stmt->execute([
|
||||
'id' => $positionId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
return 'Position geloescht.';
|
||||
}
|
||||
|
||||
private function saveQuote(): string
|
||||
{
|
||||
$instrumentId = (int) ($_POST['quote_instrument_id'] ?? 0);
|
||||
$price = (float) ($_POST['quote_price'] ?? 0);
|
||||
$currency = $this->normalizeCurrency((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency));
|
||||
$quotedAt = $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? ''));
|
||||
$source = trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual';
|
||||
|
||||
if ($instrumentId <= 0 || $price <= 0) {
|
||||
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
|
||||
}
|
||||
|
||||
$this->storeQuote($instrumentId, $price, $currency, $quotedAt, $source);
|
||||
return 'Kurs gespeichert.';
|
||||
}
|
||||
|
||||
private function refreshMarketDataPosition(): string
|
||||
{
|
||||
$positionId = (int) ($_POST['position_id'] ?? 0);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT
|
||||
p.instrument_id,
|
||||
i.name AS instrument_name,
|
||||
i.symbol,
|
||||
i.isin,
|
||||
i.quote_currency
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.id = :id AND p.owner_sub = :owner_sub
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $positionId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
throw new RuntimeException('Position nicht gefunden.');
|
||||
}
|
||||
|
||||
$instrumentId = (int) $row['instrument_id'];
|
||||
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
|
||||
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
|
||||
if ($symbol === '') {
|
||||
throw new RuntimeException('Fuer diese Aktie ist noch kein Symbol hinterlegt.');
|
||||
}
|
||||
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
|
||||
return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
|
||||
}
|
||||
|
||||
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
|
||||
if (empty($apiResult['ok'])) {
|
||||
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
|
||||
}
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
|
||||
$displayTime = (string) \module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($apiResult['fetched_at'] ?? ''),
|
||||
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
|
||||
);
|
||||
return 'Alpha Vantage lieferte fuer ' . (string) $row['instrument_name'] . ' keinen neueren Snapshot als ' . $displayTime . '.';
|
||||
}
|
||||
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $apiResult['price'],
|
||||
$quoteCurrency,
|
||||
(string) $apiResult['fetched_at'],
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.';
|
||||
}
|
||||
return 'Vorhandener Alpha-Vantage-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
|
||||
}
|
||||
|
||||
private function refreshMarketDataAll(): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT DISTINCT
|
||||
i.id,
|
||||
i.name,
|
||||
i.symbol,
|
||||
i.isin,
|
||||
i.quote_currency
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.owner_sub = :owner_sub
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('Keine Positionen fuer den API-Abruf vorhanden.');
|
||||
}
|
||||
|
||||
$fetched = 0;
|
||||
$reused = 0;
|
||||
$stale = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
$bulkRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
|
||||
if ($instrumentId <= 0 || $symbol === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
|
||||
$reused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$bulkRows[] = $row;
|
||||
}
|
||||
|
||||
if ($bulkRows !== []) {
|
||||
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
|
||||
$bulkRows
|
||||
))));
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
|
||||
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows);
|
||||
if (empty($bulkResult['ok'])) {
|
||||
throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.'));
|
||||
}
|
||||
|
||||
$bulkQuotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
|
||||
foreach ($bulkRows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
|
||||
$apiResult = $bulkQuotes[$instrumentId] ?? null;
|
||||
if (!is_array($apiResult) || !is_numeric($apiResult['price'] ?? null)) {
|
||||
$failed++;
|
||||
$errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Alpha-Vantage-Antwort.';
|
||||
continue;
|
||||
}
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
|
||||
$stale++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $apiResult['price'],
|
||||
$quoteCurrency,
|
||||
(string) $apiResult['fetched_at'],
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
$fetched++;
|
||||
} else {
|
||||
$reused++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException(
|
||||
'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. '
|
||||
. implode(' | ', array_slice($errors, 0, 3))
|
||||
);
|
||||
}
|
||||
|
||||
return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.';
|
||||
}
|
||||
|
||||
private function searchSymbol(): string
|
||||
{
|
||||
$keywords = trim((string) ($_POST['search_keywords'] ?? ''));
|
||||
$this->symbolSearchKeywords = $keywords;
|
||||
$result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $keywords);
|
||||
$this->symbolSearchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
|
||||
|
||||
if (empty($result['ok'])) {
|
||||
throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.'));
|
||||
}
|
||||
|
||||
return (string) ($result['message'] ?? 'Suche abgeschlossen.');
|
||||
}
|
||||
|
||||
private function deleteQuote(): string
|
||||
{
|
||||
$quoteId = (int) ($_POST['quote_id'] ?? 0);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM ' . $this->quoteTable . '
|
||||
WHERE id = :id
|
||||
AND instrument_id IN (
|
||||
SELECT DISTINCT instrument_id
|
||||
FROM ' . $this->positionTable . '
|
||||
WHERE owner_sub = :owner_sub
|
||||
)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $quoteId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
return 'Kurseintrag geloescht.';
|
||||
}
|
||||
|
||||
private function refreshFx(): string
|
||||
{
|
||||
$result = \module_fn('boersenchecker', 'fx_refresh', $this->defaultReportCurrency, $this->fxMaxAgeHours);
|
||||
if (empty($result['ok'])) {
|
||||
throw new RuntimeException((string) ($result['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
return (string) ($result['message'] ?? 'FX-Daten aktualisiert.');
|
||||
}
|
||||
|
||||
private function fetchPortfolios(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function fetchPositions(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT
|
||||
p.*,
|
||||
i.isin,
|
||||
i.wkn,
|
||||
i.symbol,
|
||||
i.name AS instrument_name,
|
||||
i.quote_currency,
|
||||
i.market
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.owner_sub = :owner_sub
|
||||
ORDER BY p.purchase_date DESC, p.id DESC'
|
||||
);
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function buildInstrumentList(array $positions): array
|
||||
{
|
||||
$instrumentList = [];
|
||||
foreach ($positions as $position) {
|
||||
$instrumentId = (int) $position['instrument_id'];
|
||||
if (!isset($instrumentList[$instrumentId])) {
|
||||
$instrumentList[$instrumentId] = [
|
||||
'id' => $instrumentId,
|
||||
'name' => (string) $position['instrument_name'],
|
||||
'symbol' => (string) ($position['symbol'] ?? ''),
|
||||
'isin' => (string) ($position['isin'] ?? ''),
|
||||
'quote_currency' => (string) ($position['quote_currency'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $instrumentList;
|
||||
}
|
||||
|
||||
private function fetchQuotes(array $instrumentIds): array
|
||||
{
|
||||
$quoteHistory = [];
|
||||
$latestQuotes = [];
|
||||
if ($instrumentIds === []) {
|
||||
return [$quoteHistory, $latestQuotes];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id IN (' . $placeholders . ')
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC'
|
||||
);
|
||||
$stmt->execute($instrumentIds);
|
||||
$quotes = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($quotes as $quote) {
|
||||
$instrumentId = (int) $quote['instrument_id'];
|
||||
$quoteHistory[$instrumentId][] = $quote;
|
||||
if (!isset($latestQuotes[$instrumentId])) {
|
||||
$latestQuotes[$instrumentId] = $quote;
|
||||
}
|
||||
}
|
||||
|
||||
return [$quoteHistory, $latestQuotes];
|
||||
}
|
||||
|
||||
private function buildPortfolioById(array $portfolios): array
|
||||
{
|
||||
$portfolioById = [];
|
||||
foreach ($portfolios as $portfolio) {
|
||||
$portfolio['base_currency'] = $this->normalizeCurrency((string) ($portfolio['base_currency'] ?? $this->defaultReportCurrency));
|
||||
$portfolioById[(int) $portfolio['id']] = $portfolio;
|
||||
}
|
||||
return $portfolioById;
|
||||
}
|
||||
|
||||
private function enrichPositions(array $positions, array $portfolioById, array $latestQuotes): array
|
||||
{
|
||||
$portfolioStats = [];
|
||||
foreach ($portfolioById as $portfolioId => $portfolio) {
|
||||
$portfolioStats[$portfolioId] = [
|
||||
'invested' => 0.0,
|
||||
'current' => 0.0,
|
||||
'gain' => 0.0,
|
||||
'positions' => 0,
|
||||
'has_invested' => false,
|
||||
'has_current' => false,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($positions as &$position) {
|
||||
$portfolioId = (int) $position['portfolio_id'];
|
||||
$baseCurrency = (string) ($portfolioById[$portfolioId]['base_currency'] ?? $this->defaultReportCurrency);
|
||||
$quantity = (float) $position['quantity'];
|
||||
$purchasePrice = (float) $position['purchase_price'];
|
||||
$fees = is_numeric($position['fees'] ?? null) ? (float) $position['fees'] : 0.0;
|
||||
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
|
||||
$purchaseTotalBase = $this->convertAmount($purchaseTotal, (string) $position['purchase_currency'], $baseCurrency);
|
||||
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
|
||||
$currentTotalBase = null;
|
||||
|
||||
if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) {
|
||||
$currentOriginal = $quantity * (float) $latestQuote['price'];
|
||||
$currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency, (string) ($latestQuote['source'] ?? ''));
|
||||
$position['latest_price'] = (float) $latestQuote['price'];
|
||||
$position['latest_currency'] = (string) $latestQuote['currency'];
|
||||
$position['latest_quoted_at'] = (string) $latestQuote['quoted_at'];
|
||||
$position['latest_source'] = (string) ($latestQuote['source'] ?? '');
|
||||
$position['current_total_base'] = $currentTotalBase;
|
||||
} else {
|
||||
$position['latest_price'] = null;
|
||||
$position['latest_currency'] = null;
|
||||
$position['latest_quoted_at'] = null;
|
||||
$position['latest_source'] = null;
|
||||
$position['current_total_base'] = null;
|
||||
}
|
||||
|
||||
$position['purchase_total'] = $purchaseTotal;
|
||||
$position['purchase_total_base'] = $purchaseTotalBase;
|
||||
$position['base_currency'] = $baseCurrency;
|
||||
$position['gain_base'] = $currentTotalBase !== null && $purchaseTotalBase !== null
|
||||
? $currentTotalBase - $purchaseTotalBase
|
||||
: null;
|
||||
|
||||
if (isset($portfolioStats[$portfolioId])) {
|
||||
$portfolioStats[$portfolioId]['positions']++;
|
||||
if ($purchaseTotalBase !== null) {
|
||||
$portfolioStats[$portfolioId]['invested'] += $purchaseTotalBase;
|
||||
$portfolioStats[$portfolioId]['has_invested'] = true;
|
||||
}
|
||||
if ($currentTotalBase !== null) {
|
||||
$portfolioStats[$portfolioId]['current'] += $currentTotalBase;
|
||||
$portfolioStats[$portfolioId]['has_current'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($position);
|
||||
|
||||
foreach ($portfolioStats as &$stats) {
|
||||
$stats['gain'] = ($stats['has_invested'] && $stats['has_current'])
|
||||
? $stats['current'] - $stats['invested']
|
||||
: null;
|
||||
}
|
||||
unset($stats);
|
||||
|
||||
return [$positions, $portfolioStats];
|
||||
}
|
||||
|
||||
private function latestApiQuoteForInstrument(int $instrumentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
AND source LIKE :source
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'source' => 'alphavantage:%',
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
|
||||
{
|
||||
if (!is_array($latestQuote)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
|
||||
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
|
||||
if ($latestTimestamp === false || $incomingTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $incomingTimestamp <= $latestTimestamp;
|
||||
}
|
||||
|
||||
private function upsertInstrument(array $payload): int
|
||||
{
|
||||
return $this->instrumentRegistry->save($payload);
|
||||
}
|
||||
|
||||
private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
|
||||
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
]);
|
||||
}
|
||||
|
||||
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = $this->normalizeCurrency($from);
|
||||
$to = $this->normalizeCurrency($to);
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
try {
|
||||
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
|
||||
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $value));
|
||||
return $normalized !== '' ? $normalized : $fallback;
|
||||
}
|
||||
|
||||
private function normalizeDateTimeLocal(?string $value): string
|
||||
{
|
||||
$timezone = new \DateTimeZone(nexus_display_timezone_name());
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
|
||||
if ($date instanceof \DateTimeImmutable) {
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
private function formatNumber(?float $value, int $scale = 2): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return number_format($value, $scale, ',', '.');
|
||||
}
|
||||
|
||||
private function buildAvailableOwners(): array
|
||||
{
|
||||
$owners = [];
|
||||
$currentSub = trim((string) ($this->user['sub'] ?? 'local'));
|
||||
$owners[$currentSub] = [
|
||||
'sub' => $currentSub,
|
||||
'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub,
|
||||
];
|
||||
|
||||
if (!$this->isAdmin) {
|
||||
return $owners;
|
||||
}
|
||||
|
||||
foreach (\modules()->knownAuthUsers() as $knownUser) {
|
||||
$sub = trim((string) ($knownUser['sub'] ?? ''));
|
||||
if ($sub === '') {
|
||||
continue;
|
||||
}
|
||||
$label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub));
|
||||
$owners[$sub] = [
|
||||
'sub' => $sub,
|
||||
'label' => $label !== '' ? $label : $sub,
|
||||
];
|
||||
}
|
||||
|
||||
uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label']));
|
||||
return $owners;
|
||||
}
|
||||
}
|
||||
364
custom/apps/boersenchecker/src/Support/HomePage.php
Normal file
364
custom/apps/boersenchecker/src/Support/HomePage.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class HomePage
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $ownerSub;
|
||||
private string $portfolioTable;
|
||||
private string $instrumentTable;
|
||||
private string $positionTable;
|
||||
private string $quoteTable;
|
||||
private string $defaultReportCurrency;
|
||||
private float $fxMaxAgeHours;
|
||||
private int $marketDataMinIntervalMinutes;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = \module_fn('boersenchecker', 'pdo');
|
||||
\module_fn('boersenchecker', 'ensure_schema');
|
||||
$user = \auth_user() ?? [];
|
||||
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$settings = \modules()->settings('boersenchecker');
|
||||
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
|
||||
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
|
||||
if ($this->fxMaxAgeHours <= 0) {
|
||||
$this->fxMaxAgeHours = 6.0;
|
||||
}
|
||||
$this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
|
||||
if ($this->marketDataMinIntervalMinutes <= 0) {
|
||||
$this->marketDataMinIntervalMinutes = 60;
|
||||
}
|
||||
|
||||
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
|
||||
$this->portfolioTable = $table('portfolios');
|
||||
$this->instrumentTable = $table('instruments');
|
||||
$this->positionTable = $table('positions');
|
||||
$this->quoteTable = $table('quotes');
|
||||
}
|
||||
|
||||
public function handle(): array
|
||||
{
|
||||
$notice = null;
|
||||
$error = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'refresh_current_quotes_home') {
|
||||
try {
|
||||
$notice = $this->refreshCurrentQuotesForPortfolio((int) ($_POST['portfolio_id'] ?? 0));
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$portfolios = $this->fetchPortfolios();
|
||||
$selectedPortfolioId = (int) ($_GET['portfolio_id'] ?? ($_POST['portfolio_id'] ?? 0));
|
||||
if ($selectedPortfolioId <= 0 && $portfolios !== []) {
|
||||
$selectedPortfolioId = (int) $portfolios[0]['id'];
|
||||
}
|
||||
|
||||
$positions = $selectedPortfolioId > 0 ? $this->fetchPortfolioPositions($selectedPortfolioId) : [];
|
||||
$selectedInstrumentId = (int) ($_GET['instrument_id'] ?? 0);
|
||||
if ($selectedInstrumentId <= 0 && $positions !== []) {
|
||||
$selectedInstrumentId = (int) $positions[0]['instrument_id'];
|
||||
}
|
||||
|
||||
$latestQuotes = $this->fetchLatestQuotes(array_values(array_unique(array_map(static fn (array $row): int => (int) $row['instrument_id'], $positions))));
|
||||
foreach ($positions as &$position) {
|
||||
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
|
||||
$position['latest_price'] = is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null) ? (float) $latestQuote['price'] : null;
|
||||
$position['latest_currency'] = is_array($latestQuote) ? (string) ($latestQuote['currency'] ?? '') : '';
|
||||
$position['latest_quoted_at'] = is_array($latestQuote) ? (string) ($latestQuote['quoted_at'] ?? '') : '';
|
||||
$position['latest_source'] = is_array($latestQuote) ? (string) ($latestQuote['source'] ?? '') : '';
|
||||
$position['current_total_report'] = null;
|
||||
$position['gain_report'] = null;
|
||||
$position['gain_percent'] = null;
|
||||
if ($position['latest_price'] !== null) {
|
||||
$currentNative = (float) $position['latest_price'] * (float) ($position['quantity'] ?? 0);
|
||||
$currentReport = $this->convertAmount(
|
||||
$currentNative,
|
||||
(string) ($position['latest_currency'] ?: ($position['quote_currency'] ?? $this->defaultReportCurrency)),
|
||||
$this->defaultReportCurrency,
|
||||
(string) ($position['latest_source'] ?? '')
|
||||
);
|
||||
$position['current_total_report'] = $currentReport;
|
||||
if ($position['purchase_total_report'] !== null && $currentReport !== null) {
|
||||
$gain = $currentReport - (float) $position['purchase_total_report'];
|
||||
$position['gain_report'] = $gain;
|
||||
$base = (float) $position['purchase_total_report'];
|
||||
$position['gain_percent'] = $base > 0 ? ($gain / $base) * 100 : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($position);
|
||||
|
||||
$selectedInstrument = null;
|
||||
foreach ($positions as $position) {
|
||||
if ((int) $position['instrument_id'] === $selectedInstrumentId) {
|
||||
$selectedInstrument = $position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'notice' => $notice,
|
||||
'error' => $error,
|
||||
'portfolios' => $portfolios,
|
||||
'selectedPortfolioId' => $selectedPortfolioId,
|
||||
'positions' => $positions,
|
||||
'selectedInstrumentId' => $selectedInstrumentId,
|
||||
'selectedInstrument' => $selectedInstrument,
|
||||
'summary' => $this->buildSummary($positions),
|
||||
'defaultReportCurrency' => $this->defaultReportCurrency,
|
||||
'chartEndpoint' => '/api/boersenchecker/index.php?path=v1/chart-data',
|
||||
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
|
||||
];
|
||||
}
|
||||
|
||||
private function refreshCurrentQuotesForPortfolio(int $portfolioId): string
|
||||
{
|
||||
if ($portfolioId <= 0) {
|
||||
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT DISTINCT i.id, i.name, i.symbol, i.isin, i.quote_currency
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'portfolio_id' => $portfolioId,
|
||||
]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('In diesem Depot sind keine Aktien vorhanden.');
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
$reused = 0;
|
||||
$stale = 0;
|
||||
$bulkCandidates = [];
|
||||
foreach ($rows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
|
||||
if ($instrumentId <= 0 || $symbol === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$latest = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
$latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
|
||||
$reused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$bulkCandidates[] = $row;
|
||||
}
|
||||
|
||||
if ($bulkCandidates !== []) {
|
||||
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
|
||||
$bulkCandidates
|
||||
))));
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
|
||||
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkCandidates);
|
||||
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
|
||||
foreach ($bulkCandidates as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$quote = $quotes[$instrumentId] ?? null;
|
||||
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$latest = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
if ($this->isApiSnapshotStale($latest, (string) ($quote['fetched_at'] ?? ''))) {
|
||||
$stale++;
|
||||
continue;
|
||||
}
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $quote['price'],
|
||||
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
|
||||
(string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')),
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
$updated++;
|
||||
} else {
|
||||
$reused++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet, ' . $stale . ' API-Snapshots waren nicht neuer.';
|
||||
}
|
||||
|
||||
private function fetchPortfolios(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function fetchPortfolioPositions(int $portfolioId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT p.*, i.name AS instrument_name, i.symbol, i.isin, i.wkn, i.quote_currency, i.market
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$stmt->execute([
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'portfolio_id' => $portfolioId,
|
||||
]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($rows as &$row) {
|
||||
$quantity = (float) ($row['quantity'] ?? 0);
|
||||
$purchasePrice = (float) ($row['purchase_price'] ?? 0);
|
||||
$fees = is_numeric($row['fees'] ?? null) ? (float) $row['fees'] : 0.0;
|
||||
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
|
||||
$row['purchase_total'] = $purchaseTotal;
|
||||
$row['purchase_total_report'] = $this->convertAmount(
|
||||
$purchaseTotal,
|
||||
(string) ($row['purchase_currency'] ?? $this->defaultReportCurrency),
|
||||
$this->defaultReportCurrency
|
||||
);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function fetchLatestQuotes(array $instrumentIds): array
|
||||
{
|
||||
$result = [];
|
||||
if ($instrumentIds === []) {
|
||||
return $result;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id IN (' . $placeholders . ')
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC'
|
||||
);
|
||||
$stmt->execute($instrumentIds);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$instrumentId = (int) $row['instrument_id'];
|
||||
if (!isset($result[$instrumentId])) {
|
||||
$result[$instrumentId] = $row;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function latestApiQuoteForInstrument(int $instrumentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id = :instrument_id AND source LIKE :source
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'source' => 'alphavantage:%',
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
|
||||
{
|
||||
if (!is_array($latestQuote)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
|
||||
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
|
||||
if ($latestTimestamp === false || $incomingTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $incomingTimestamp <= $latestTimestamp;
|
||||
}
|
||||
|
||||
private function buildSummary(array $positions): array
|
||||
{
|
||||
$invested = 0.0;
|
||||
$current = 0.0;
|
||||
$hasInvested = false;
|
||||
$hasCurrent = false;
|
||||
$best = null;
|
||||
$worst = null;
|
||||
|
||||
foreach ($positions as $position) {
|
||||
if (is_numeric($position['purchase_total_report'] ?? null)) {
|
||||
$invested += (float) $position['purchase_total_report'];
|
||||
$hasInvested = true;
|
||||
}
|
||||
if (is_numeric($position['current_total_report'] ?? null)) {
|
||||
$current += (float) $position['current_total_report'];
|
||||
$hasCurrent = true;
|
||||
}
|
||||
if (is_numeric($position['gain_percent'] ?? null)) {
|
||||
if ($best === null || (float) $position['gain_percent'] > (float) ($best['gain_percent'] ?? 0)) {
|
||||
$best = $position;
|
||||
}
|
||||
if ($worst === null || (float) $position['gain_percent'] < (float) ($worst['gain_percent'] ?? 0)) {
|
||||
$worst = $position;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'positions' => count($positions),
|
||||
'invested' => $hasInvested ? $invested : null,
|
||||
'current' => $hasCurrent ? $current : null,
|
||||
'gain' => ($hasInvested && $hasCurrent) ? $current - $invested : null,
|
||||
'best' => $best,
|
||||
'worst' => $worst,
|
||||
];
|
||||
}
|
||||
|
||||
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = $this->normalizeCurrency($from, $this->defaultReportCurrency);
|
||||
$to = $this->normalizeCurrency($to, $this->defaultReportCurrency);
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
try {
|
||||
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
|
||||
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $value));
|
||||
return $normalized !== '' ? $normalized : $fallback;
|
||||
}
|
||||
}
|
||||
351
custom/apps/boersenchecker/src/Support/InstrumentPage.php
Normal file
351
custom/apps/boersenchecker/src/Support/InstrumentPage.php
Normal file
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class InstrumentPage
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $ownerSub;
|
||||
private string $instrumentTable;
|
||||
private string $positionTable;
|
||||
private string $quoteTable;
|
||||
private string $defaultReportCurrency;
|
||||
private float $fxMaxAgeHours;
|
||||
private string $searchKeywords = '';
|
||||
private array $searchResults = [];
|
||||
private int $selectedInstrumentOverrideId = 0;
|
||||
private InstrumentRegistry $instrumentRegistry;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = \module_fn('boersenchecker', 'pdo');
|
||||
\module_fn('boersenchecker', 'ensure_schema');
|
||||
$user = \auth_user() ?? [];
|
||||
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$settings = \modules()->settings('boersenchecker');
|
||||
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
|
||||
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
|
||||
if ($this->fxMaxAgeHours <= 0) {
|
||||
$this->fxMaxAgeHours = 6.0;
|
||||
}
|
||||
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
|
||||
$this->instrumentTable = $table('instruments');
|
||||
$this->positionTable = $table('positions');
|
||||
$this->quoteTable = $table('quotes');
|
||||
$this->instrumentRegistry = new InstrumentRegistry(
|
||||
$this->pdo,
|
||||
$this->instrumentTable,
|
||||
$this->positionTable,
|
||||
$this->quoteTable,
|
||||
);
|
||||
}
|
||||
|
||||
public function handle(): array
|
||||
{
|
||||
$notice = null;
|
||||
$error = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$notice = $this->handlePost();
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$instruments = $this->fetchInstruments();
|
||||
$selectedInstrumentId = $this->selectedInstrumentOverrideId > 0
|
||||
? $this->selectedInstrumentOverrideId
|
||||
: (int) ($_GET['instrument_id'] ?? ($_POST['instrument_id'] ?? 0));
|
||||
if ($selectedInstrumentId <= 0 && $instruments !== []) {
|
||||
$selectedInstrumentId = (int) $instruments[0]['id'];
|
||||
}
|
||||
|
||||
$selectedInstrument = null;
|
||||
foreach ($instruments as $instrument) {
|
||||
if ((int) $instrument['id'] === $selectedInstrumentId) {
|
||||
$selectedInstrument = $instrument;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$quotes = $selectedInstrumentId > 0 ? $this->fetchQuotes($selectedInstrumentId) : [];
|
||||
|
||||
$candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
|
||||
$candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
|
||||
$candidateIsin = trim((string) ($_GET['isin_candidate'] ?? ''));
|
||||
$candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
|
||||
$candidateCurrency = strtoupper(trim((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
|
||||
if ($selectedInstrument === null && ($candidateName !== '' || $candidateSymbol !== '' || $candidateMarket !== '')) {
|
||||
$selectedInstrument = [
|
||||
'id' => 0,
|
||||
'name' => $candidateName,
|
||||
'symbol' => $candidateSymbol,
|
||||
'isin' => $candidateIsin,
|
||||
'market' => $candidateMarket,
|
||||
'quote_currency' => $candidateCurrency,
|
||||
'wkn' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'notice' => $notice,
|
||||
'error' => $error,
|
||||
'instruments' => $instruments,
|
||||
'selectedInstrument' => $selectedInstrument,
|
||||
'selectedInstrumentId' => $selectedInstrumentId,
|
||||
'quotes' => $quotes,
|
||||
'searchKeywords' => $this->searchKeywords,
|
||||
'searchResults' => $this->searchResults,
|
||||
'defaultReportCurrency' => $this->defaultReportCurrency,
|
||||
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
|
||||
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
|
||||
];
|
||||
}
|
||||
|
||||
private function handlePost(): string
|
||||
{
|
||||
$action = trim((string) ($_POST['action'] ?? ''));
|
||||
return match ($action) {
|
||||
'save_instrument' => $this->saveInstrument(),
|
||||
'save_quote' => $this->saveQuote(),
|
||||
'delete_quote' => $this->deleteQuote(),
|
||||
'refresh_market_data_instrument' => $this->refreshInstrumentQuote(),
|
||||
'search_symbol' => $this->searchSymbol(),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function fetchInstruments(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT DISTINCT i.*
|
||||
FROM ' . $this->instrumentTable . ' i
|
||||
INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
|
||||
WHERE p.owner_sub = :owner_sub
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function fetchQuotes(int $instrumentId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC
|
||||
LIMIT 30'
|
||||
);
|
||||
$stmt->execute(['instrument_id' => $instrumentId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function saveInstrument(): string
|
||||
{
|
||||
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
|
||||
if ($instrumentId <= 0) {
|
||||
throw new RuntimeException('Bitte eine Aktie auswaehlen.');
|
||||
}
|
||||
$this->assertInstrumentAccessible($instrumentId);
|
||||
|
||||
$resolvedId = $this->instrumentRegistry->save([
|
||||
'id' => $instrumentId,
|
||||
'name' => $_POST['instrument_name'] ?? '',
|
||||
'symbol' => $_POST['symbol'] ?? '',
|
||||
'isin' => $_POST['isin'] ?? '',
|
||||
'wkn' => $_POST['wkn'] ?? '',
|
||||
'market' => $_POST['market'] ?? '',
|
||||
'quote_currency' => $_POST['quote_currency'] ?? $this->defaultReportCurrency,
|
||||
]);
|
||||
$this->selectedInstrumentOverrideId = $resolvedId;
|
||||
|
||||
return $resolvedId === $instrumentId
|
||||
? 'Aktie aktualisiert.'
|
||||
: 'Aktie aktualisiert und mit bestehendem Systemeintrag zusammengefuehrt.';
|
||||
}
|
||||
|
||||
private function saveQuote(): string
|
||||
{
|
||||
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
|
||||
$price = (float) ($_POST['quote_price'] ?? 0);
|
||||
if ($instrumentId <= 0 || $price <= 0) {
|
||||
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
|
||||
}
|
||||
$this->assertInstrumentAccessible($instrumentId);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
|
||||
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => strtoupper(trim((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
|
||||
'quoted_at' => $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')),
|
||||
'source' => trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual',
|
||||
]);
|
||||
return 'Kurs gespeichert.';
|
||||
}
|
||||
|
||||
private function deleteQuote(): string
|
||||
{
|
||||
$quoteId = (int) ($_POST['quote_id'] ?? 0);
|
||||
if ($quoteId <= 0) {
|
||||
throw new RuntimeException('Bitte einen Kurseintrag auswaehlen.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT q.instrument_id
|
||||
FROM ' . $this->quoteTable . ' q
|
||||
WHERE q.id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $quoteId]);
|
||||
$instrumentId = (int) $stmt->fetchColumn();
|
||||
$this->assertInstrumentAccessible($instrumentId);
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->quoteTable . ' WHERE id = :id');
|
||||
$stmt->execute(['id' => $quoteId]);
|
||||
return 'Kurs geloescht.';
|
||||
}
|
||||
|
||||
private function refreshInstrumentQuote(): string
|
||||
{
|
||||
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
|
||||
$instrument = $this->assertInstrumentAccessible($instrumentId);
|
||||
$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
|
||||
if ($symbol === '') {
|
||||
throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.');
|
||||
}
|
||||
|
||||
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
|
||||
if (empty($apiResult['ok'])) {
|
||||
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
|
||||
}
|
||||
$quoteCurrency = strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
|
||||
$displayTime = (string) \module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($apiResult['fetched_at'] ?? ''),
|
||||
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
|
||||
);
|
||||
return 'Alpha Vantage lieferte keinen neueren Snapshot als ' . $displayTime . '.';
|
||||
}
|
||||
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $apiResult['price'],
|
||||
$quoteCurrency,
|
||||
(string) $apiResult['fetched_at'],
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
|
||||
);
|
||||
|
||||
return !empty($storeResult['inserted'])
|
||||
? 'Alpha-Vantage-Kurs gespeichert.'
|
||||
: 'Vorhandener Alpha-Vantage-Snapshot wiederverwendet.';
|
||||
}
|
||||
|
||||
private function searchSymbol(): string
|
||||
{
|
||||
$this->searchKeywords = trim((string) ($_POST['search_keywords'] ?? ''));
|
||||
$result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $this->searchKeywords);
|
||||
$this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
|
||||
if (empty($result['ok'])) {
|
||||
throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.'));
|
||||
}
|
||||
return (string) ($result['message'] ?? 'Suche abgeschlossen.');
|
||||
}
|
||||
|
||||
private function assertInstrumentAccessible(int $instrumentId): array
|
||||
{
|
||||
if ($instrumentId <= 0) {
|
||||
throw new RuntimeException('Aktie nicht gefunden.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT DISTINCT i.*
|
||||
FROM ' . $this->instrumentTable . ' i
|
||||
INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
|
||||
WHERE i.id = :id AND p.owner_sub = :owner_sub
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $instrumentId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
$instrument = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($instrument)) {
|
||||
throw new RuntimeException('Aktie ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
return $instrument;
|
||||
}
|
||||
|
||||
private function normalizeDateTimeLocal(?string $value): string
|
||||
{
|
||||
$timezone = new \DateTimeZone(nexus_display_timezone_name());
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
|
||||
if ($date instanceof \DateTimeImmutable) {
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
private function latestApiQuoteForInstrument(int $instrumentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
AND source LIKE :source
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'source' => 'alphavantage:%',
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
|
||||
{
|
||||
if (!is_array($latestQuote)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
|
||||
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
|
||||
if ($latestTimestamp === false || $incomingTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $incomingTimestamp <= $latestTimestamp;
|
||||
}
|
||||
}
|
||||
190
custom/apps/boersenchecker/src/Support/InstrumentRegistry.php
Normal file
190
custom/apps/boersenchecker/src/Support/InstrumentRegistry.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class InstrumentRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
private string $instrumentTable,
|
||||
private string $positionTable,
|
||||
private string $quoteTable,
|
||||
) {
|
||||
}
|
||||
|
||||
public function save(array $payload): int
|
||||
{
|
||||
$currentId = (int) ($payload['id'] ?? 0);
|
||||
$data = $this->normalizePayload($payload);
|
||||
$matchingId = $this->findMatchingInstrumentId($data, $currentId);
|
||||
|
||||
if ($currentId > 0 && $matchingId > 0 && $matchingId !== $currentId) {
|
||||
return $this->mergeIntoExistingInstrument($currentId, $matchingId, $data);
|
||||
}
|
||||
|
||||
if ($currentId > 0) {
|
||||
$this->updateInstrument($currentId, $data);
|
||||
return $currentId;
|
||||
}
|
||||
|
||||
if ($matchingId > 0) {
|
||||
$this->updateInstrument($matchingId, $data);
|
||||
return $matchingId;
|
||||
}
|
||||
|
||||
return $this->insertInstrument($data);
|
||||
}
|
||||
|
||||
public function findMatchingInstrumentId(array $payload, int $excludeId = 0): ?int
|
||||
{
|
||||
$data = $this->normalizePayload($payload);
|
||||
$conditions = [];
|
||||
$excludeSql = $excludeId > 0 ? ' AND id <> :exclude_id' : '';
|
||||
|
||||
if ($data['isin'] !== null) {
|
||||
$conditions[] = [
|
||||
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin' . $excludeSql . ' LIMIT 1',
|
||||
'params' => ['isin' => $data['isin']],
|
||||
];
|
||||
}
|
||||
|
||||
if ($data['symbol'] !== null && $data['market'] !== null) {
|
||||
$conditions[] = [
|
||||
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND market = :market' . $excludeSql . ' LIMIT 1',
|
||||
'params' => ['symbol' => $data['symbol'], 'market' => $data['market']],
|
||||
];
|
||||
}
|
||||
|
||||
if ($data['symbol'] !== null && $data['name'] !== '') {
|
||||
$conditions[] = [
|
||||
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name' . $excludeSql . ' LIMIT 1',
|
||||
'params' => ['symbol' => $data['symbol'], 'name' => $data['name']],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($conditions as $condition) {
|
||||
$params = $condition['params'];
|
||||
if ($excludeId > 0) {
|
||||
$params['exclude_id'] = $excludeId;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare($condition['sql']);
|
||||
$stmt->execute($params);
|
||||
$id = $stmt->fetchColumn();
|
||||
if ($id !== false) {
|
||||
return (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizePayload(array $payload): array
|
||||
{
|
||||
$data = [
|
||||
'isin' => $this->normalizeUpper($payload['isin'] ?? null),
|
||||
'wkn' => $this->normalizeUpper($payload['wkn'] ?? null),
|
||||
'symbol' => $this->normalizeUpper($payload['symbol'] ?? null),
|
||||
'name' => trim((string) ($payload['name'] ?? '')),
|
||||
'quote_currency' => $this->normalizeUpper($payload['quote_currency'] ?? 'EUR', 'EUR'),
|
||||
'market' => trim((string) ($payload['market'] ?? '')) ?: null,
|
||||
];
|
||||
|
||||
if ($data['name'] === '') {
|
||||
throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function normalizeUpper(mixed $value, string $fallback = ''): ?string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $value));
|
||||
if ($normalized !== '') {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}
|
||||
|
||||
private function updateInstrument(int $instrumentId, array $data): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->instrumentTable . '
|
||||
SET isin = :isin,
|
||||
wkn = :wkn,
|
||||
symbol = :symbol,
|
||||
name = :name,
|
||||
quote_currency = :quote_currency,
|
||||
market = :market,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute($data + ['id' => $instrumentId]);
|
||||
}
|
||||
|
||||
private function insertInstrument(array $data): int
|
||||
{
|
||||
$driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
|
||||
if ($driver === 'pgsql') {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
|
||||
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)
|
||||
RETURNING id'
|
||||
);
|
||||
$stmt->execute($data);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
|
||||
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)'
|
||||
);
|
||||
$stmt->execute($data);
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
private function mergeIntoExistingInstrument(int $sourceId, int $targetId, array $data): int
|
||||
{
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$this->updateInstrument($targetId, $data);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->positionTable . '
|
||||
SET instrument_id = :target_id, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE instrument_id = :source_id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'target_id' => $targetId,
|
||||
'source_id' => $sourceId,
|
||||
]);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->quoteTable . '
|
||||
SET instrument_id = :target_id
|
||||
WHERE instrument_id = :source_id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'target_id' => $targetId,
|
||||
'source_id' => $sourceId,
|
||||
]);
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->instrumentTable . ' WHERE id = :id');
|
||||
$stmt->execute(['id' => $sourceId]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $targetId;
|
||||
}
|
||||
}
|
||||
17
custom/apps/fx-rates/api/index.php
Normal file
17
custom/apps/fx-rates/api/index.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
|
||||
$prefix = '/api/fx-rates/index.php/';
|
||||
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
|
||||
|
||||
if ($relativePath === '') {
|
||||
$relativePath = str_starts_with($requestPath, $prefix)
|
||||
? substr($requestPath, strlen($prefix))
|
||||
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
|
||||
}
|
||||
|
||||
(new Modules\FxRates\Api\Router(dirname(__DIR__)))->handle($relativePath);
|
||||
206
custom/apps/fx-rates/assets/css/app.css
Normal file
206
custom/apps/fx-rates/assets/css/app.css
Normal file
@@ -0,0 +1,206 @@
|
||||
#fx-rates-app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-shell {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-grid,
|
||||
#fx-rates-app .fx-cards,
|
||||
#fx-rates-app .fx-table-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-cards {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-table-grid {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-card,
|
||||
#fx-rates-app .fx-panel {
|
||||
padding: 22px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-card h3,
|
||||
#fx-rates-app .fx-panel h3,
|
||||
#fx-rates-app .fx-panel h4 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-copy,
|
||||
#fx-rates-app .fx-meta,
|
||||
#fx-rates-app .fx-label,
|
||||
#fx-rates-app .fx-note {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-kicker {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: #4338ca;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-stat-value {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-inline-actions,
|
||||
#fx-rates-app .fx-form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
padding: 0 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(79, 70, 229, 0.18);
|
||||
background: linear-gradient(135deg, #4f46e5, #2563eb);
|
||||
color: #ffffff;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-button--ghost {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-message {
|
||||
min-height: 1.5rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-message.is-error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-message.is-success {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-form-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-field label {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-input,
|
||||
#fx-rates-app .fx-select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-input:focus,
|
||||
#fx-rates-app .fx-select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(79, 70, 229, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12);
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-token-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-token {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(79, 70, 229, 0.08);
|
||||
color: #312e81;
|
||||
border: 1px solid rgba(79, 70, 229, 0.14);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-table th,
|
||||
#fx-rates-app .fx-table td {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-table th {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#fx-rates-app .fx-widget-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
#fx-rates-app .fx-table-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
780
custom/apps/fx-rates/assets/js/app.js
Normal file
780
custom/apps/fx-rates/assets/js/app.js
Normal file
@@ -0,0 +1,780 @@
|
||||
(function () {
|
||||
function parseSections(root, options) {
|
||||
const configured = Array.isArray(options.sections) ? options.sections : null;
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(root.dataset.sectionsJson || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function cx() {
|
||||
return Array.from(arguments).filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function fmtNumber(value, digits) {
|
||||
if (value === null || value === undefined || value === '' || !Number.isFinite(Number(value))) {
|
||||
return 'n/a';
|
||||
}
|
||||
return Number(value).toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: digits === undefined ? 6 : digits,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDate(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
let normalized = raw;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
||||
normalized = `${raw}T00:00:00Z`;
|
||||
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
|
||||
normalized = `${raw.replace(' ', 'T')}Z`;
|
||||
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
|
||||
normalized = `${raw}Z`;
|
||||
}
|
||||
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return raw;
|
||||
}
|
||||
return parsed.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function readMessage(error) {
|
||||
return error && error.message ? error.message : 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
function createFxRatesApp(root, options) {
|
||||
const apiBase = options.apiBase || root.dataset.apiBase || '/api/fx-rates/index.php?path=v1';
|
||||
const sectionDefs = parseSections(root, options);
|
||||
const fallbackSections = [
|
||||
{ key: 'overview', label: 'Uebersicht', summary: 'Letzte Abrufe, Umrechnung, Verlauf und manuelle Aktualisierung.' },
|
||||
{ key: 'currencies', label: 'Waehrungen', summary: 'Bevorzugte Waehrungen, Anzeige-Basis und Katalog verwalten.' },
|
||||
{ key: 'settings', label: 'Setup', summary: 'Provider, Token, Refresh-Regeln und Laufzeitparameter pflegen.' },
|
||||
];
|
||||
const sectionMap = new Map();
|
||||
sectionDefs.concat(fallbackSections).forEach((section) => {
|
||||
const key = String(section.key || '').trim();
|
||||
if (!key || sectionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
sectionMap.set(key, {
|
||||
key,
|
||||
label: String(section.label || key),
|
||||
summary: String(section.summary || ''),
|
||||
});
|
||||
});
|
||||
const sections = Array.from(sectionMap.values());
|
||||
const initialTab = String(options.activeView || root.dataset.activeView || 'overview').trim() || 'overview';
|
||||
|
||||
const state = {
|
||||
activeTab: sectionMap.has(initialTab) ? initialTab : sections[0].key,
|
||||
loading: true,
|
||||
saving: false,
|
||||
syncingCatalog: false,
|
||||
message: '',
|
||||
error: '',
|
||||
settings: null,
|
||||
statuses: [],
|
||||
recentFetches: [],
|
||||
conversion: null,
|
||||
history: [],
|
||||
currencyProbe: null,
|
||||
};
|
||||
|
||||
function setState(patch) {
|
||||
Object.assign(state, patch);
|
||||
render();
|
||||
}
|
||||
|
||||
async function request(path, options) {
|
||||
const buildApiUrl = (relativePath) => {
|
||||
const url = new URL(apiBase, window.location.origin);
|
||||
const [resourcePath, queryString] = String(relativePath || '').split('?');
|
||||
const currentPath = url.searchParams.get('path') || '';
|
||||
url.searchParams.set('path', `${currentPath.replace(/\/+$/, '')}/${String(resourcePath || '').replace(/^\/+/, '')}`.replace(/^\/+/, ''));
|
||||
if (queryString) {
|
||||
const extraParams = new URLSearchParams(queryString);
|
||||
extraParams.forEach((value, key) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const response = await fetch(buildApiUrl(path), {
|
||||
method: options && options.method ? options.method : 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options && options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: options && options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
function currentSettings() {
|
||||
return state.settings || {
|
||||
preferred_currencies: ['EUR', 'USD'],
|
||||
default_base_currency: 'EUR',
|
||||
display_base_currency: 'EUR',
|
||||
refresh_max_age_hours: 6,
|
||||
refresh_max_age_minutes: 360,
|
||||
currency_catalog: [],
|
||||
};
|
||||
}
|
||||
|
||||
function historyPairs() {
|
||||
const settings = currentSettings();
|
||||
const base = String(settings.display_base_currency || settings.default_base_currency || 'EUR').toUpperCase();
|
||||
const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : [];
|
||||
return preferred
|
||||
.map((code) => String(code || '').toUpperCase())
|
||||
.filter((code) => code && code !== base)
|
||||
.slice(0, 4)
|
||||
.map((code) => ({ from: base, to: code }));
|
||||
}
|
||||
|
||||
function normalizedTimestamp(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
|
||||
return `${raw.replace(' ', 'T')}Z`;
|
||||
}
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
|
||||
return `${raw}Z`;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function buildHistoryMatrix(historyEntries) {
|
||||
const columns = [];
|
||||
const rowsByKey = new Map();
|
||||
|
||||
historyEntries.forEach((entry) => {
|
||||
const pair = entry && entry.pair ? entry.pair : null;
|
||||
const columnKey = pair ? `${String(pair.from || '').toUpperCase()}/${String(pair.to || '').toUpperCase()}` : '';
|
||||
if (!columnKey) {
|
||||
return;
|
||||
}
|
||||
if (!columns.includes(columnKey)) {
|
||||
columns.push(columnKey);
|
||||
}
|
||||
|
||||
(Array.isArray(entry.rows) ? entry.rows : []).forEach((row) => {
|
||||
const fetchId = Number(row && row.fetch_id);
|
||||
const fetchedAt = normalizedTimestamp(row && row.fetched_at);
|
||||
const rowKey = fetchId > 0 ? `fetch:${fetchId}` : `time:${fetchedAt}`;
|
||||
if (!rowKey || rowKey === 'time:') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rowsByKey.has(rowKey)) {
|
||||
rowsByKey.set(rowKey, {
|
||||
key: rowKey,
|
||||
fetch_id: fetchId > 0 ? fetchId : null,
|
||||
fetched_at: row && row.fetched_at ? String(row.fetched_at) : '',
|
||||
provider: row && row.provider ? String(row.provider) : '',
|
||||
values: {},
|
||||
});
|
||||
}
|
||||
|
||||
const bucket = rowsByKey.get(rowKey);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bucket.fetched_at && row && row.fetched_at) {
|
||||
bucket.fetched_at = String(row.fetched_at);
|
||||
}
|
||||
if (!bucket.provider && row && row.provider) {
|
||||
bucket.provider = String(row.provider);
|
||||
}
|
||||
bucket.values[columnKey] = Number.isFinite(Number(row && row.rate)) ? Number(row.rate) : null;
|
||||
});
|
||||
});
|
||||
|
||||
const rows = Array.from(rowsByKey.values()).sort((left, right) => {
|
||||
const leftTs = new Date(normalizedTimestamp(left.fetched_at) || 0).getTime();
|
||||
const rightTs = new Date(normalizedTimestamp(right.fetched_at) || 0).getTime();
|
||||
return rightTs - leftTs;
|
||||
});
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOverviewData() {
|
||||
const [statuses, recentFetches] = await Promise.all([
|
||||
request('status'),
|
||||
request('recent-fetches?limit=15'),
|
||||
]);
|
||||
const pairs = historyPairs();
|
||||
const historyRows = await Promise.all(pairs.map(async (pair) => ({
|
||||
pair,
|
||||
rows: await request(`history?from=${encodeURIComponent(pair.from)}&to=${encodeURIComponent(pair.to)}&limit=12`),
|
||||
})));
|
||||
|
||||
setState({
|
||||
statuses: Array.isArray(statuses) ? statuses : [],
|
||||
recentFetches: Array.isArray(recentFetches) ? recentFetches : [],
|
||||
history: historyRows,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBootstrap() {
|
||||
try {
|
||||
const [settings, currencyProbe] = await Promise.all([
|
||||
request('settings'),
|
||||
request('currency-catalog/probe').catch(() => null),
|
||||
]);
|
||||
|
||||
setState({
|
||||
settings,
|
||||
currencyProbe,
|
||||
loading: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
await loadOverviewData();
|
||||
} catch (error) {
|
||||
setState({
|
||||
loading: false,
|
||||
error: readMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runRefresh(force) {
|
||||
setState({
|
||||
saving: true,
|
||||
error: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await request('refresh', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
force: force === true,
|
||||
trigger_source: 'manual',
|
||||
},
|
||||
});
|
||||
|
||||
await loadOverviewData();
|
||||
setState({
|
||||
saving: false,
|
||||
message: result && result.reused
|
||||
? `Kein neuer Abruf. Letzter Snapshot ist noch innerhalb von ${fmtNumber(currentSettings().refresh_max_age_hours, 2)} Stunden.`
|
||||
: `Aktuelle Kurse gespeichert. ${fmtNumber(result?.updated_count, 0)} Werte aktualisiert.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setState({
|
||||
saving: false,
|
||||
error: readMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runConversion(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const amount = Number(formData.get('amount') || 0);
|
||||
const from = String(formData.get('from') || '').toUpperCase();
|
||||
const to = String(formData.get('to') || '').toUpperCase();
|
||||
|
||||
try {
|
||||
const rate = await request(`rate?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
||||
const resolved = Number(rate?.rate || 0);
|
||||
if (!Number.isFinite(resolved) || resolved <= 0) {
|
||||
throw new Error('Kein passender Kurs verfuegbar.');
|
||||
}
|
||||
setState({
|
||||
conversion: {
|
||||
amount,
|
||||
from,
|
||||
to,
|
||||
rate: resolved,
|
||||
converted: amount * resolved,
|
||||
},
|
||||
error: '',
|
||||
message: '',
|
||||
});
|
||||
} catch (error) {
|
||||
setState({
|
||||
conversion: null,
|
||||
error: readMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const preferred = String(formData.get('preferred_currencies') || '')
|
||||
.split(',')
|
||||
.map((item) => String(item || '').trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
|
||||
setState({ saving: true, error: '', message: '' });
|
||||
|
||||
try {
|
||||
const body = {};
|
||||
['provider', 'api_version', 'api_url', 'currencies_url', 'api_key', 'default_base_currency', 'display_base_currency', 'schedule_timezone'].forEach((key) => {
|
||||
if (formData.has(key)) {
|
||||
body[key] = formData.get(key);
|
||||
}
|
||||
});
|
||||
if (formData.has('timeout_sec')) {
|
||||
body.timeout_sec = Number(formData.get('timeout_sec') || 10);
|
||||
}
|
||||
if (formData.has('refresh_max_age_hours')) {
|
||||
body.refresh_max_age_hours = Number(formData.get('refresh_max_age_hours') || 6);
|
||||
}
|
||||
if (formData.has('preferred_currencies')) {
|
||||
body.preferred_currencies = preferred;
|
||||
}
|
||||
|
||||
const settings = await request('settings', {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
|
||||
setState({
|
||||
saving: false,
|
||||
settings,
|
||||
message: 'Setup gespeichert.',
|
||||
});
|
||||
await loadOverviewData();
|
||||
} catch (error) {
|
||||
setState({
|
||||
saving: false,
|
||||
error: readMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCatalog() {
|
||||
setState({ syncingCatalog: true, error: '', message: '' });
|
||||
try {
|
||||
const result = await request('currency-catalog/sync', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
});
|
||||
setState({
|
||||
syncingCatalog: false,
|
||||
settings: result?.settings || state.settings,
|
||||
message: `Waehrungskatalog synchronisiert. ${fmtNumber(result?.sync?.synced_count, 0)} Eintraege geladen.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setState({
|
||||
syncingCatalog: false,
|
||||
error: readMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openWidgetSetup() {
|
||||
window.dispatchEvent(new CustomEvent('desktop:open-app', {
|
||||
detail: {
|
||||
appId: 'user-self-management',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
return `
|
||||
<aside class="window-app-sidebar">
|
||||
<div class="window-app-brand">
|
||||
<p class="window-app-kicker">Modul</p>
|
||||
<h1>Waehrungs-Checker</h1>
|
||||
<p>Waehrungskurse, Historie, API und Widget-Aktualisierung in einer gemeinsamen Modulanwendung.</p>
|
||||
</div>
|
||||
<div class="window-app-nav-list">
|
||||
${sections.map((section) => `
|
||||
<button class="${cx('window-app-nav-button', state.activeTab === section.key && 'is-active')}" type="button" data-action="tab" data-tab="${escapeHtml(section.key)}">
|
||||
<strong>${escapeHtml(section.label)}</strong>
|
||||
<span class="window-app-meta">${escapeHtml(section.summary)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const settings = currentSettings();
|
||||
const latest = state.statuses[0] || null;
|
||||
const base = String(settings.default_base_currency || 'EUR').toUpperCase();
|
||||
const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : [];
|
||||
const historyMatrix = buildHistoryMatrix(state.history);
|
||||
|
||||
return `
|
||||
<div class="fx-stack">
|
||||
<section class="window-app-card">
|
||||
<p class="window-app-kicker">Aktualisierung</p>
|
||||
<h3>Externer Kursabruf</h3>
|
||||
<p class="window-app-copy">Das Oeffnen der App loest keinen Abruf aus. Manuell und per API wird nur aktualisiert, wenn die bestehenden Kurse aelter als ${fmtNumber(settings.refresh_max_age_hours, 2)} Stunden sind, ausser mit force.</p>
|
||||
<div class="fx-inline-actions">
|
||||
<button class="fx-button" type="button" data-action="refresh">${state.saving ? 'Aktualisiert ...' : 'Kurse aktualisieren'}</button>
|
||||
<button class="fx-button fx-button--ghost" type="button" data-action="refresh-force">${state.saving ? 'Aktualisiert ...' : 'Erzwungen aktualisieren'}</button>
|
||||
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Widget-Auswahl oeffnen</button>
|
||||
</div>
|
||||
<p class="${cx('fx-message', state.error && 'is-error', state.message && !state.error && 'is-success')}">${escapeHtml(state.error || state.message || '')}</p>
|
||||
</section>
|
||||
|
||||
<section class="fx-cards">
|
||||
<article class="fx-card">
|
||||
<p class="fx-kicker">Letzter Abruf</p>
|
||||
<div class="fx-stat-value">${escapeHtml(latest ? fmtDate(latest.fetched_at) : 'Noch keiner')}</div>
|
||||
<p class="fx-copy">${escapeHtml(latest ? `${latest.base_currency} · ${latest.provider} · ${latest.trigger_source_label || latest.trigger_source}` : 'Es wurden noch keine Kurse gespeichert.')}</p>
|
||||
</article>
|
||||
<article class="fx-card">
|
||||
<p class="fx-kicker">Standard-Basis</p>
|
||||
<div class="fx-stat-value">${escapeHtml(base)}</div>
|
||||
<p class="fx-copy">Anzeige-Basis ${escapeHtml(String(settings.display_base_currency || base).toUpperCase())}</p>
|
||||
</article>
|
||||
<article class="fx-card">
|
||||
<p class="fx-kicker">Bevorzugte Waehrungen</p>
|
||||
<div class="fx-stat-value">${escapeHtml(String(preferred.length || 0))}</div>
|
||||
<p class="fx-copy">${escapeHtml(preferred.join(', ') || 'Keine Auswahl')}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="fx-table-grid">
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Umrechnung</p>
|
||||
<h3>Direkter Kursvergleich</h3>
|
||||
<form class="fx-form-grid" data-form="convert">
|
||||
<div class="fx-field">
|
||||
<label for="fx-amount">Betrag</label>
|
||||
<input id="fx-amount" class="fx-input" type="number" name="amount" min="0" step="0.00000001" value="1">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-from">Von</label>
|
||||
<select id="fx-from" class="fx-select" name="from">
|
||||
${preferred.map((currency) => `<option value="${escapeHtml(currency)}">${escapeHtml(currency)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-to">Nach</label>
|
||||
<select id="fx-to" class="fx-select" name="to">
|
||||
${preferred.map((currency, index) => `<option value="${escapeHtml(currency)}" ${index === 1 ? 'selected' : ''}>${escapeHtml(currency)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="fx-form-actions">
|
||||
<button class="fx-button" type="submit">Umrechnen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="fx-copy">${state.conversion
|
||||
? `${fmtNumber(state.conversion.amount, 8)} ${state.conversion.from} = ${fmtNumber(state.conversion.converted, 8)} ${state.conversion.to} bei Kurs ${fmtNumber(state.conversion.rate, 8)}`
|
||||
: 'Noch keine Umrechnung berechnet.'}</p>
|
||||
</section>
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Widget</p>
|
||||
<h3>Desktop-Infobereich</h3>
|
||||
<div class="fx-widget-note">
|
||||
<p class="fx-copy">Das Widget kann im Setup unter Infobereich aktiviert werden und verwendet dieselbe Refresh-Regel wie die App und die API.</p>
|
||||
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Setup oeffnen</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Verlauf</p>
|
||||
<h3>Neueste Kursverlaeufe</h3>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<th>Quelle</th>
|
||||
${historyMatrix.columns.map((column) => `<th>${escapeHtml(column)}</th>`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${historyMatrix.rows.length === 0
|
||||
? `<tr><td colspan="${String(2 + historyMatrix.columns.length)}">Noch keine Verlaufsdaten vorhanden.</td></tr>`
|
||||
: historyMatrix.rows.map((row) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(fmtDate(row.fetched_at))}</td>
|
||||
<td>${escapeHtml(row.provider || 'n/a')}</td>
|
||||
${historyMatrix.columns.map((column) => `<td>${escapeHtml(fmtNumber(row.values[column], 8))}</td>`).join('')}
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Abrufe</p>
|
||||
<h3>Letzte gespeicherte Snapshots</h3>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<th>Basis</th>
|
||||
<th>Provider</th>
|
||||
<th>Ausloeser</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${state.recentFetches.length === 0
|
||||
? '<tr><td colspan="4">Noch keine Abrufe gespeichert.</td></tr>'
|
||||
: state.recentFetches.map((fetch) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(fmtDate(fetch.fetched_at))}</td>
|
||||
<td>${escapeHtml(fetch.base_currency || 'n/a')}</td>
|
||||
<td>${escapeHtml(fetch.provider || 'n/a')}</td>
|
||||
<td>${escapeHtml(fetch.trigger_source_label || fetch.trigger_source || 'n/a')}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCurrencies() {
|
||||
const settings = currentSettings();
|
||||
const catalog = Array.isArray(settings.currency_catalog) ? settings.currency_catalog : [];
|
||||
return `
|
||||
<div class="fx-stack">
|
||||
<section class="window-app-card">
|
||||
<p class="window-app-kicker">Katalog</p>
|
||||
<h3>Bevorzugte Waehrungen</h3>
|
||||
<p class="window-app-copy">Der Katalog wird aus dem konfigurierten Provider synchronisiert. Die Anzeige-Basis muss Teil der bevorzugten Waehrungen sein.</p>
|
||||
<div class="fx-inline-actions">
|
||||
<button class="fx-button" type="button" data-action="sync-catalog">${state.syncingCatalog ? 'Synchronisiert ...' : 'Waehrungskatalog synchronisieren'}</button>
|
||||
</div>
|
||||
<p class="fx-copy">Letzte Katalog-Synchronisierung: ${escapeHtml(settings.currency_catalog_synced_at ? fmtDate(settings.currency_catalog_synced_at) : 'noch nie')}</p>
|
||||
</section>
|
||||
|
||||
<form class="fx-panel fx-stack" data-form="settings">
|
||||
<div class="fx-form-grid">
|
||||
<div class="fx-field">
|
||||
<label for="fx-display-base">Anzeige-Basis</label>
|
||||
<input id="fx-display-base" class="fx-input" type="text" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-default-base">Standard-Basis</label>
|
||||
<input id="fx-default-base" class="fx-input" type="text" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-preferred">Bevorzugte Waehrungen</label>
|
||||
<input id="fx-preferred" class="fx-input" type="text" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-form-actions">
|
||||
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Waehrungen speichern'}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Verfuegbarer Katalog</p>
|
||||
<div class="fx-token-row">
|
||||
${catalog.length === 0
|
||||
? '<span class="fx-token">Noch kein Katalog geladen</span>'
|
||||
: catalog.map((item) => `<span class="fx-token">${escapeHtml(item.code)} · ${escapeHtml(item.name)}</span>`).join('')}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const settings = currentSettings();
|
||||
return `
|
||||
<form class="fx-stack" data-form="settings">
|
||||
<section class="window-app-card">
|
||||
<p class="window-app-kicker">Provider</p>
|
||||
<h3>Externe Quelle und Token</h3>
|
||||
<div class="fx-form-grid">
|
||||
<div class="fx-field">
|
||||
<label for="fx-provider">Provider</label>
|
||||
<input id="fx-provider" class="fx-input" type="text" name="provider" value="${escapeHtml(settings.provider || 'currencyapi')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-api-version">API Version</label>
|
||||
<select id="fx-api-version" class="fx-select" name="api_version">
|
||||
<option value="v2" ${settings.api_version === 'v2' ? 'selected' : ''}>v2</option>
|
||||
<option value="v3" ${settings.api_version === 'v3' ? 'selected' : ''}>v3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-api-url">API URL</label>
|
||||
<input id="fx-api-url" class="fx-input" type="text" name="api_url" value="${escapeHtml(settings.api_url || '')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-currencies-url">Currencies URL</label>
|
||||
<input id="fx-currencies-url" class="fx-input" type="text" name="currencies_url" value="${escapeHtml(settings.currencies_url || settings.api_url || '')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-api-key">API Key</label>
|
||||
<input id="fx-api-key" class="fx-input" type="password" name="api_key" value="${escapeHtml(settings.api_key || '')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-timeout">Timeout (Sek.)</label>
|
||||
<input id="fx-timeout" class="fx-input" type="number" name="timeout_sec" min="2" step="1" value="${escapeHtml(String(settings.timeout_sec || 10))}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-refresh-hours">Refresh-Sperre (Std.)</label>
|
||||
<input id="fx-refresh-hours" class="fx-input" type="number" name="refresh_max_age_hours" min="1" step="0.5" value="${escapeHtml(String(settings.refresh_max_age_hours || 6))}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-timezone">Zeitzone</label>
|
||||
<input id="fx-timezone" class="fx-input" type="text" name="schedule_timezone" value="${escapeHtml(settings.schedule_timezone || 'Europe/Berlin')}">
|
||||
</div>
|
||||
<input type="hidden" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
|
||||
<input type="hidden" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
|
||||
<input type="hidden" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
|
||||
</div>
|
||||
<div class="fx-form-actions">
|
||||
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Setup speichern'}</button>
|
||||
</div>
|
||||
<p class="fx-copy">Der importierte Token wird als Default uebernommen, kann hier aber projektweit geaendert werden.</p>
|
||||
</section>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMain() {
|
||||
const current = sectionMap.get(state.activeTab) || sections[0];
|
||||
let content = '';
|
||||
if (state.activeTab === 'currencies') {
|
||||
content = renderCurrencies();
|
||||
} else if (state.activeTab === 'settings') {
|
||||
content = renderSettings();
|
||||
} else {
|
||||
content = renderOverview();
|
||||
}
|
||||
|
||||
return `
|
||||
<main class="window-app-main">
|
||||
<section class="window-app-hero">
|
||||
<div>
|
||||
<p class="window-app-kicker">Waehrungs-Checker</p>
|
||||
<h2 class="window-app-title">${escapeHtml(current.label)}</h2>
|
||||
<p class="window-app-copy">${escapeHtml(current.summary)}</p>
|
||||
</div>
|
||||
<div class="window-app-pill-row">
|
||||
<span class="window-app-pill">API aktiv</span>
|
||||
<span class="window-app-pill">Widget optional</span>
|
||||
<span class="window-app-pill">Provider ${escapeHtml(String((state.settings && state.settings.provider) || 'currencyapi'))}</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="window-app-panel fx-shell">
|
||||
${content}
|
||||
</div>
|
||||
</main>
|
||||
`;
|
||||
}
|
||||
|
||||
function bind(rootNode) {
|
||||
rootNode.querySelectorAll('[data-action="tab"]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
setState({ activeTab: String(button.getAttribute('data-tab') || 'overview') });
|
||||
});
|
||||
});
|
||||
|
||||
rootNode.querySelectorAll('[data-action="refresh"]').forEach((button) => {
|
||||
button.addEventListener('click', () => runRefresh(false));
|
||||
});
|
||||
rootNode.querySelectorAll('[data-action="refresh-force"]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const confirmed = window.confirm('Der Abruf wird jetzt unabhaengig vom Alter der gespeicherten Kurse erzwungen. Wirklich fortfahren?');
|
||||
if (confirmed) {
|
||||
runRefresh(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
rootNode.querySelectorAll('[data-action="sync-catalog"]').forEach((button) => {
|
||||
button.addEventListener('click', () => syncCatalog());
|
||||
});
|
||||
rootNode.querySelectorAll('[data-action="open-widget-setup"]').forEach((button) => {
|
||||
button.addEventListener('click', () => openWidgetSetup());
|
||||
});
|
||||
|
||||
rootNode.querySelectorAll('[data-form="convert"]').forEach((form) => {
|
||||
form.addEventListener('submit', runConversion);
|
||||
});
|
||||
rootNode.querySelectorAll('[data-form="settings"]').forEach((form) => {
|
||||
form.addEventListener('submit', saveSettings);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (state.loading) {
|
||||
root.innerHTML = '<div class="window-app-loading"><p class="window-app-copy">Waehrungsdaten werden geladen.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.error && !state.settings) {
|
||||
root.innerHTML = `<div class="window-app-error-state"><p class="window-app-message is-error">${escapeHtml(state.error)}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="window-app-shell">
|
||||
<div class="window-app-frame">
|
||||
${renderNav()}
|
||||
${renderMain()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
bind(root);
|
||||
}
|
||||
|
||||
render();
|
||||
loadBootstrap();
|
||||
}
|
||||
|
||||
window.initFxRatesApp = function initFxRatesApp(rootNode, options) {
|
||||
const root = rootNode || document.getElementById('fx-rates-app');
|
||||
if (!root || root.dataset.moduleInitialized === '1') {
|
||||
return;
|
||||
}
|
||||
root.dataset.moduleInitialized = '1';
|
||||
createFxRatesApp(root, options && typeof options === 'object' ? options : {});
|
||||
};
|
||||
|
||||
if (document.getElementById('fx-rates-app')) {
|
||||
window.initFxRatesApp(document.getElementById('fx-rates-app'), {});
|
||||
}
|
||||
})();
|
||||
42
custom/apps/fx-rates/bootstrap.php
Normal file
42
custom/apps/fx-rates/bootstrap.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\FxRates\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$path = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';
|
||||
if (is_file($path)) {
|
||||
require_once $path;
|
||||
}
|
||||
});
|
||||
|
||||
$moduleName = 'fx-rates';
|
||||
$moduleBasePath = __DIR__;
|
||||
|
||||
if (method_exists(modules(), 'registerFunction')) {
|
||||
modules()->registerFunction($moduleName, 'service', static function () use ($moduleBasePath): \Modules\FxRates\Domain\FxRatesService {
|
||||
static $service = null;
|
||||
|
||||
if ($service instanceof \Modules\FxRates\Domain\FxRatesService) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
$moduleConfig = \Modules\FxRates\Infrastructure\ModuleConfig::load($moduleBasePath);
|
||||
$settingsStore = new \Modules\FxRates\Infrastructure\SettingsStore($moduleConfig);
|
||||
$settings = $settingsStore->load();
|
||||
$pdo = \Modules\FxRates\Infrastructure\ConnectionFactory::make($settingsStore);
|
||||
$repository = new \Modules\FxRates\Infrastructure\FxRatesRepository(
|
||||
$pdo,
|
||||
$moduleConfig->tablePrefix()
|
||||
);
|
||||
$repository->ensureSchema();
|
||||
|
||||
$service = new \Modules\FxRates\Domain\FxRatesService($repository, $settings);
|
||||
|
||||
return $service;
|
||||
});
|
||||
}
|
||||
15
custom/apps/fx-rates/config/example.config.php
Normal file
15
custom/apps/fx-rates/config/example.config.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'FX_RATES_PROVIDER' => 'currencyapi',
|
||||
'FX_RATES_API_VERSION' => 'v2',
|
||||
'FX_RATES_API_URL' => 'https://currencyapi.net',
|
||||
'FX_RATES_CURRENCIES_URL' => 'https://currencyapi.net',
|
||||
'FX_RATES_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1',
|
||||
'FX_RATES_TIMEOUT' => '10',
|
||||
'FX_RATES_REFRESH_MAX_AGE_HOURS' => '6',
|
||||
'FX_RATES_DEFAULT_BASE_CURRENCY' => 'EUR',
|
||||
'FX_RATES_DISPLAY_BASE_CURRENCY' => 'EUR',
|
||||
'FX_RATES_SCHEDULE_TIMEZONE' => 'Europe/Berlin',
|
||||
];
|
||||
22
custom/apps/fx-rates/config/module.php
Normal file
22
custom/apps/fx-rates/config/module.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default_project_key' => getenv('FX_RATES_DEFAULT_PROJECT_KEY') ?: 'fx-main',
|
||||
'use_project_database' => true,
|
||||
'table_prefix' => 'fxrate_',
|
||||
'settings_file' => dirname(__DIR__, 3) . '/data/fx-rates/settings.json',
|
||||
'provider' => getenv('FX_RATES_PROVIDER') ?: (getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi'),
|
||||
'api_version' => getenv('FX_RATES_API_VERSION') ?: 'v2',
|
||||
'api_url' => getenv('FX_RATES_API_URL') ?: (getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net'),
|
||||
'currencies_url' => getenv('FX_RATES_CURRENCIES_URL') ?: (getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: (getenv('FX_RATES_API_URL') ?: (getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net'))),
|
||||
'api_key' => getenv('FX_RATES_API_KEY') ?: (getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1'),
|
||||
'timeout_sec' => (int) (getenv('FX_RATES_TIMEOUT') ?: (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10)),
|
||||
'refresh_max_age_hours' => (float) (getenv('FX_RATES_REFRESH_MAX_AGE_HOURS') ?: 6),
|
||||
'default_base_currency' => getenv('FX_RATES_DEFAULT_BASE_CURRENCY') ?: 'EUR',
|
||||
'display_base_currency' => getenv('FX_RATES_DISPLAY_BASE_CURRENCY') ?: (getenv('FX_RATES_DEFAULT_BASE_CURRENCY') ?: 'EUR'),
|
||||
'preferred_currencies' => ['EUR', 'USD', 'DOGE', 'BTC'],
|
||||
'currency_catalog' => [],
|
||||
'currency_catalog_synced_at' => '',
|
||||
'schedule_timezone' => getenv('FX_RATES_SCHEDULE_TIMEZONE') ?: nexus_display_timezone_name(),
|
||||
];
|
||||
19
custom/apps/fx-rates/design.json
Normal file
19
custom/apps/fx-rates/design.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"key": "overview",
|
||||
"label": "Uebersicht",
|
||||
"summary": "Letzte Abrufe, Umrechnung, Verlauf und manuelle Aktualisierung."
|
||||
},
|
||||
{
|
||||
"key": "currencies",
|
||||
"label": "Waehrungen",
|
||||
"summary": "Bevorzugte Waehrungen, Anzeige-Basis und Katalog verwalten."
|
||||
},
|
||||
{
|
||||
"key": "settings",
|
||||
"label": "Setup",
|
||||
"summary": "Provider, Token, Refresh-Regeln und Laufzeitparameter pflegen."
|
||||
}
|
||||
]
|
||||
}
|
||||
60
custom/apps/fx-rates/desktop.php
Normal file
60
custom/apps/fx-rates/desktop.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$manifest = [];
|
||||
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
|
||||
if ($manifestRaw !== false) {
|
||||
$decoded = json_decode($manifestRaw, true);
|
||||
$manifest = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$design = [];
|
||||
$designRaw = is_file(__DIR__ . '/design.json') ? file_get_contents(__DIR__ . '/design.json') : false;
|
||||
if ($designRaw !== false) {
|
||||
$decoded = json_decode($designRaw, true);
|
||||
$design = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$sections = array_values(array_filter(
|
||||
is_array($design['sections'] ?? null) ? $design['sections'] : [],
|
||||
static fn (mixed $section): bool => is_array($section)
|
||||
));
|
||||
|
||||
$assetVersion = static function (string $relativePath): string {
|
||||
$fullPath = __DIR__ . '/' . ltrim($relativePath, '/');
|
||||
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
|
||||
|
||||
return $mtime === false ? '0' : (string) $mtime;
|
||||
};
|
||||
|
||||
return [
|
||||
'app_id' => 'fx-rates',
|
||||
'title' => (string) ($manifest['title'] ?? 'Waehrungs-Checker'),
|
||||
'icon' => 'FX',
|
||||
'entry_route' => '/apps/fx-rates',
|
||||
'window_mode' => 'window',
|
||||
'content_mode' => 'native-module',
|
||||
'default_width' => 1120,
|
||||
'default_height' => 760,
|
||||
'supports_widget' => true,
|
||||
'supports_tray' => true,
|
||||
'module_name' => 'finance',
|
||||
'summary' => (string) ($manifest['description'] ?? 'Waehrungskurse, Verlauf, API und Desktop-Widget fuer manuelle Aktualisierung.'),
|
||||
'native_module' => [
|
||||
'initializer' => 'initFxRatesApp',
|
||||
'options' => [
|
||||
'apiBase' => '/api/fx-rates/index.php?path=v1',
|
||||
'activeView' => 'overview',
|
||||
'sections' => $sections,
|
||||
],
|
||||
'assets' => [
|
||||
'styles' => [
|
||||
['href' => '/module-assets/index.php?module=fx-rates&path=assets/css/app.css&v=' . rawurlencode($assetVersion('assets/css/app.css'))],
|
||||
],
|
||||
'scripts' => [
|
||||
['src' => '/module-assets/index.php?module=fx-rates&path=assets/js/app.js&v=' . rawurlencode($assetVersion('assets/js/app.js'))],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
29
custom/apps/fx-rates/docs/README.md
Normal file
29
custom/apps/fx-rates/docs/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Waehrungs-Checker Modul
|
||||
|
||||
Das Modul `fx-rates` stellt gespeicherte Waehrungskurse, Historie, Umrechnung und manuelle Refresh-Aktionen bereit.
|
||||
|
||||
## Kernpunkte
|
||||
|
||||
- Desktop-App unter `custom/apps/fx-rates/desktop.php`
|
||||
- API unter `/api/fx-rates/...`
|
||||
- Widget fuer den rechten Desktop-Infobereich ueber `config/widgets.php`
|
||||
- externer Provider-Token wird aus dem alten Bestand als Default uebernommen
|
||||
- oeffentliche Einstiege laufen ueber `public/apps/fx-rates/index.php` und `public/api/fx-rates/index.php`
|
||||
|
||||
## Refresh-Regeln
|
||||
|
||||
- das Oeffnen der App loest keinen externen Abruf aus
|
||||
- `POST /api/fx-rates/.../refresh` aktualisiert nur, wenn der letzte Snapshot aelter als die konfigurierte Sperrzeit ist
|
||||
- mit `force=true` kann ein Abruf explizit erzwungen werden
|
||||
- das Desktop-Widget nutzt dieselbe Logik
|
||||
|
||||
## API
|
||||
|
||||
Wichtige Endpunkte:
|
||||
|
||||
- `GET /api/fx-rates/v1/latest`
|
||||
- `GET /api/fx-rates/v1/rate`
|
||||
- `GET /api/fx-rates/v1/history`
|
||||
- `POST /api/fx-rates/v1/refresh`
|
||||
- `GET /api/fx-rates/v1/settings`
|
||||
- `PUT /api/fx-rates/v1/settings`
|
||||
90
custom/apps/fx-rates/module.json
Normal file
90
custom/apps/fx-rates/module.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "fx-rates",
|
||||
"title": "Waehrungs-Checker",
|
||||
"version": "0.1.0",
|
||||
"description": "Zentrales Modul fuer Waehrungskurse, Historie, API-Abrufe und Desktop-Widget.",
|
||||
"enabled_by_default": true,
|
||||
"app_scope": "module",
|
||||
"installable": true,
|
||||
"desktop": {
|
||||
"available": true,
|
||||
"show_on_desktop": true,
|
||||
"show_in_start_menu": true
|
||||
},
|
||||
"widgets": [
|
||||
{
|
||||
"widget_id": "fx-rates-refresh",
|
||||
"title": "Waehrungskurse",
|
||||
"icon": "FX",
|
||||
"zone": "sidebar",
|
||||
"default_enabled": true,
|
||||
"supports_public_home": false,
|
||||
"summary": "Aktuelle FX-Kurse pruefen und bei Bedarf manuell aktualisieren.",
|
||||
"content": "Ein API-Abruf wird nur ausgefuehrt, wenn der letzte Snapshot alt genug ist, ausser bei explizitem Force.",
|
||||
"launch_app_id": "fx-rates",
|
||||
"action_label": "App oeffnen",
|
||||
"widget_type": "fx-rates-refresh",
|
||||
"status_api": "/api/fx-rates/index.php?path=v1/status",
|
||||
"refresh_api": "/api/fx-rates/index.php?path=v1/refresh"
|
||||
}
|
||||
],
|
||||
"cron_jobs": [
|
||||
{
|
||||
"job_id": "refresh-rates",
|
||||
"label": "FX-Kurse aktualisieren",
|
||||
"description": "Laedt neue Wechselkurse, sofern der letzte Snapshot nicht mehr frisch genug ist.",
|
||||
"endpoint_path": "/api/fx-rates/index.php?path=v1/refresh",
|
||||
"method": "POST",
|
||||
"payload": {
|
||||
"trigger_source": "cron"
|
||||
},
|
||||
"default_enabled": false,
|
||||
"default_cron": "0 */6 * * *",
|
||||
"lock_minutes": 15
|
||||
},
|
||||
{
|
||||
"job_id": "sync-currency-catalog",
|
||||
"label": "FX-Waehrungskatalog synchronisieren",
|
||||
"description": "Aktualisiert den internen Waehrungskatalog des Moduls.",
|
||||
"endpoint_path": "/api/fx-rates/index.php?path=v1/currency-catalog/sync",
|
||||
"method": "POST",
|
||||
"payload": {
|
||||
"trigger_source": "cron"
|
||||
},
|
||||
"default_enabled": false,
|
||||
"default_cron": "30 3 * * 1",
|
||||
"lock_minutes": 30
|
||||
}
|
||||
],
|
||||
"setup": {
|
||||
"sections": {
|
||||
"database": true
|
||||
},
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Projekt-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Unterstuetzt legacy currencyapi.net und currencyapi.com v3." },
|
||||
{ "name": "api_version", "label": "FX API Version", "type": "select", "required": false, "help": "Steuert die Endpoint-Version unabhaengig von der Domain." },
|
||||
{ "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." },
|
||||
{ "name": "currencies_url", "label": "FX Waehrungs-URL", "type": "text", "required": false, "help": "Optional separate Basis-URL fuer den Waehrungskatalog." },
|
||||
{ "name": "api_key", "label": "FX API Key", "type": "password", "required": false },
|
||||
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
|
||||
{ "name": "refresh_max_age_hours", "label": "Max. Alter fuer API-Refresh (Std.)", "type": "number", "required": false, "help": "Blockiert neue manuelle/API-Refreshes, solange der letzte gespeicherte Abruf juenger ist. Nur mit force darf frueher aktualisiert werden." },
|
||||
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false },
|
||||
{ "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "text", "required": false },
|
||||
{ "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "text", "required": false, "help": "Kommagetrennte Liste, z.B. EUR,USD,DOGE,BTC." },
|
||||
{ "name": "schedule_timezone", "label": "Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
59
custom/apps/fx-rates/pages/index.php
Normal file
59
custom/apps/fx-rates/pages/index.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
ModuleHttp::requireDesktopAccess($projectRoot);
|
||||
|
||||
$design = [];
|
||||
$designRaw = is_file(dirname(__DIR__) . '/design.json') ? file_get_contents(dirname(__DIR__) . '/design.json') : false;
|
||||
if ($designRaw !== false) {
|
||||
$decoded = json_decode($designRaw, true);
|
||||
$design = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$assetVersion = static function (string $relativePath): string {
|
||||
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
|
||||
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
|
||||
return $mtime === false ? '0' : (string) $mtime;
|
||||
};
|
||||
|
||||
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
|
||||
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
$sectionKeys = array_values(array_filter(array_map(
|
||||
static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '',
|
||||
$sections
|
||||
)));
|
||||
if ($sectionKeys === []) {
|
||||
$sectionKeys = ['overview'];
|
||||
}
|
||||
if (!in_array($activeView, $sectionKeys, true)) {
|
||||
$activeView = $sectionKeys[0];
|
||||
}
|
||||
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Waehrungs-Checker</title>
|
||||
<link rel="stylesheet" href="/assets/desktop/desktop.css">
|
||||
<link rel="stylesheet" href="/module-assets/index.php?module=fx-rates&path=assets/css/app.css&v=<?= htmlspecialchars($assetVersion('assets/css/app.css'), ENT_QUOTES) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="fx-rates-app"
|
||||
data-api-base="/api/fx-rates/index.php?path=v1"
|
||||
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
|
||||
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
|
||||
></div>
|
||||
<script src="/module-assets/index.php?module=fx-rates&path=assets/js/app.js&v=<?= htmlspecialchars($assetVersion('assets/js/app.js'), ENT_QUOTES) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
323
custom/apps/fx-rates/src/Api/Router.php
Normal file
323
custom/apps/fx-rates/src/Api/Router.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Api;
|
||||
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
use Modules\FxRates\Infrastructure\ConnectionFactory;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository;
|
||||
use Modules\FxRates\Infrastructure\ModuleConfig;
|
||||
use Modules\FxRates\Infrastructure\SettingsStore;
|
||||
use RuntimeException;
|
||||
|
||||
final class Router
|
||||
{
|
||||
private ModuleConfig $moduleConfig;
|
||||
private SettingsStore $settingsStore;
|
||||
private ?FxRatesService $service = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $moduleBasePath
|
||||
) {
|
||||
$this->moduleConfig = ModuleConfig::load($moduleBasePath);
|
||||
$this->settingsStore = new SettingsStore($this->moduleConfig);
|
||||
}
|
||||
|
||||
public function handle(string $relativePath): never
|
||||
{
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$path = trim($relativePath, '/');
|
||||
|
||||
try {
|
||||
if ($path === 'v1/health' && $method === 'GET') {
|
||||
$this->respond(['ok' => true, 'module' => 'fx-rates']);
|
||||
}
|
||||
|
||||
if ($path === 'v1/endpoints' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->endpointCatalog()]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/status' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->service()->latestStatuses()]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/recent-fetches' && $method === 'GET') {
|
||||
$limit = max(1, min(50, (int) ($_GET['limit'] ?? 12)));
|
||||
$this->respond(['data' => $this->service()->recentFetches($limit)]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/latest' && $method === 'GET') {
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
if ($symbols === null) {
|
||||
$settings = $this->settingsStore->load();
|
||||
$symbols = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : null;
|
||||
}
|
||||
$snapshot = $this->service()->snapshot($base, null, $symbols, null);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/fetch' && $method === 'GET') {
|
||||
$fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0));
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$snapshot = $this->service()->snapshotByFetchId($fetchId, $base, $symbols);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/nearest' && $method === 'GET') {
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$snapshot = $this->service()->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/snapshot' && $method === 'GET') {
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$snapshot = $this->service()->snapshot($base, $at, $symbols, $windowMinutes);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/rate' && $method === 'GET') {
|
||||
$from = $this->stringOrNull($_GET['from'] ?? null);
|
||||
$to = $this->stringOrNull($_GET['to'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$rate = $this->service()->findRate($from, $to, $at, $windowMinutes);
|
||||
$this->respond(['data' => $rate]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/history' && $method === 'GET') {
|
||||
$from = $this->stringOrNull($_GET['from'] ?? null);
|
||||
$to = $this->stringOrNull($_GET['to'] ?? null);
|
||||
$fromAt = $this->stringOrNull($_GET['from_at'] ?? null);
|
||||
$toAt = $this->stringOrNull($_GET['to_at'] ?? null);
|
||||
$limit = max(1, min(1000, (int) ($_GET['limit'] ?? 200)));
|
||||
$history = $this->service()->history((string) $from, (string) $to, $fromAt, $toAt, $limit);
|
||||
$this->respond(['data' => $history]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/refresh' && $method === 'POST') {
|
||||
$input = $this->input();
|
||||
$base = $this->stringOrNull($input['base'] ?? null);
|
||||
$force = !empty($input['force']);
|
||||
$maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null)
|
||||
? (int) $input['max_age_minutes']
|
||||
: (is_numeric($input['max_age_hours'] ?? null) ? (int) round(((float) $input['max_age_hours']) * 60) : null);
|
||||
|
||||
$result = $force
|
||||
? $this->service()->refreshLatestRates(null, $base, (string) ($input['trigger_source'] ?? 'manual'))
|
||||
: $this->service()->autoRefreshLatestRates($base, null, $maxAgeMinutes, (string) ($input['trigger_source'] ?? 'api'));
|
||||
|
||||
$this->respond(['data' => $result], 201);
|
||||
}
|
||||
|
||||
if ($path === 'v1/probe' && $method === 'GET') {
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$this->respond(['data' => $this->service()->probeLatestRates($base)]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/currency-catalog/probe' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->service()->probeCurrencyCatalog()]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/currency-catalog/sync' && $method === 'POST') {
|
||||
$result = $this->service()->refreshCurrencyCatalog();
|
||||
$current = $this->settingsStore->load();
|
||||
$catalog = [];
|
||||
foreach (is_array($result['currencies'] ?? null) ? $result['currencies'] : [] as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$catalog[$code] = ['code' => $code, 'name' => $name];
|
||||
}
|
||||
|
||||
foreach ([
|
||||
(string) ($current['default_base_currency'] ?? ''),
|
||||
(string) ($current['display_base_currency'] ?? ''),
|
||||
...((is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [])),
|
||||
] as $code) {
|
||||
$code = strtoupper(trim((string) $code));
|
||||
if ($code !== '' && !isset($catalog[$code])) {
|
||||
$catalog[$code] = ['code' => $code, 'name' => $code];
|
||||
}
|
||||
}
|
||||
ksort($catalog);
|
||||
|
||||
$settings = $this->settingsStore->save([
|
||||
'currency_catalog' => array_values($catalog),
|
||||
'currency_catalog_synced_at' => gmdate(DATE_ATOM),
|
||||
]);
|
||||
|
||||
$this->reloadService();
|
||||
$this->respond(['data' => [
|
||||
'sync' => $result,
|
||||
'settings' => $settings,
|
||||
]], 201);
|
||||
}
|
||||
|
||||
if ($path === 'v1/settings' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->settingsStore->load()]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/settings' && $method === 'PUT') {
|
||||
$payload = $this->normalizeSettingsPayload($this->input());
|
||||
$settings = $this->settingsStore->save($payload);
|
||||
$this->reloadService();
|
||||
$this->respond(['data' => $settings]);
|
||||
}
|
||||
|
||||
$this->respond(['error' => 'Unbekannter API-Pfad.'], 404);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->respond([
|
||||
'error' => 'FX-API Fehler.',
|
||||
'context' => ['message' => $exception->getMessage()],
|
||||
], $exception instanceof RuntimeException ? 500 : 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function service(): FxRatesService
|
||||
{
|
||||
if ($this->service instanceof FxRatesService) {
|
||||
return $this->service;
|
||||
}
|
||||
|
||||
$repository = new FxRatesRepository(
|
||||
ConnectionFactory::make($this->settingsStore),
|
||||
$this->moduleConfig->tablePrefix()
|
||||
);
|
||||
$repository->ensureSchema();
|
||||
|
||||
return $this->service = new FxRatesService($repository, $this->settingsStore->load());
|
||||
}
|
||||
|
||||
private function reloadService(): void
|
||||
{
|
||||
$this->service = null;
|
||||
}
|
||||
|
||||
private function respond(array $payload, int $statusCode = 200): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function input(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function parseCsv(mixed $value): ?array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$items = $value;
|
||||
} else {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$items = explode(',', $value);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$item = strtoupper(trim((string) $item));
|
||||
if ($item !== '') {
|
||||
$result[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
$result = array_values(array_unique($result));
|
||||
return $result !== [] ? $result : null;
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
|
||||
private function normalizeSettingsPayload(array $input): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
foreach (['provider', 'api_version', 'api_url', 'currencies_url', 'api_key', 'default_base_currency', 'display_base_currency', 'schedule_timezone'] as $key) {
|
||||
if (array_key_exists($key, $input)) {
|
||||
$payload[$key] = $input[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('timeout_sec', $input)) {
|
||||
$payload['timeout_sec'] = (int) $input['timeout_sec'];
|
||||
}
|
||||
if (array_key_exists('refresh_max_age_hours', $input)) {
|
||||
$payload['refresh_max_age_hours'] = (float) $input['refresh_max_age_hours'];
|
||||
}
|
||||
if (array_key_exists('refresh_max_age_minutes', $input) && !array_key_exists('refresh_max_age_hours', $input)) {
|
||||
$payload['refresh_max_age_hours'] = ((float) $input['refresh_max_age_minutes']) / 60;
|
||||
}
|
||||
|
||||
if (array_key_exists('preferred_currencies', $input)) {
|
||||
$payload['preferred_currencies'] = $input['preferred_currencies'];
|
||||
}
|
||||
if (array_key_exists('currency_catalog', $input)) {
|
||||
$payload['currency_catalog'] = $input['currency_catalog'];
|
||||
}
|
||||
if (array_key_exists('currency_catalog_synced_at', $input)) {
|
||||
$payload['currency_catalog_synced_at'] = $input['currency_catalog_synced_at'];
|
||||
}
|
||||
if (array_key_exists('use_separate_db', $input)) {
|
||||
$payload['use_separate_db'] = !empty($input['use_separate_db']);
|
||||
}
|
||||
if (array_key_exists('db', $input) && is_array($input['db'])) {
|
||||
$payload['db'] = $input['db'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function endpointCatalog(): array
|
||||
{
|
||||
return [
|
||||
'module' => 'fx-rates',
|
||||
'version' => 'v1',
|
||||
'languages' => ['de', 'en'],
|
||||
'endpoints' => [
|
||||
['path' => '/api/fx-rates/v1/endpoints', 'method' => 'GET'],
|
||||
['path' => '/api/fx-rates/v1/latest', 'method' => 'GET', 'params' => ['base', 'symbols']],
|
||||
['path' => '/api/fx-rates/v1/fetch', 'method' => 'GET', 'params' => ['fetch_id', 'base', 'symbols']],
|
||||
['path' => '/api/fx-rates/v1/nearest', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes']],
|
||||
['path' => '/api/fx-rates/v1/snapshot', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes']],
|
||||
['path' => '/api/fx-rates/v1/rate', 'method' => 'GET', 'params' => ['from', 'to', 'at', 'window_minutes']],
|
||||
['path' => '/api/fx-rates/v1/history', 'method' => 'GET', 'params' => ['from', 'to', 'from_at', 'to_at', 'limit']],
|
||||
['path' => '/api/fx-rates/v1/refresh', 'method' => 'POST', 'body' => ['base', 'force', 'max_age_minutes', 'max_age_hours']],
|
||||
['path' => '/api/fx-rates/v1/status', 'method' => 'GET'],
|
||||
['path' => '/api/fx-rates/v1/recent-fetches', 'method' => 'GET', 'params' => ['limit']],
|
||||
['path' => '/api/fx-rates/v1/probe', 'method' => 'GET', 'params' => ['base']],
|
||||
['path' => '/api/fx-rates/v1/currency-catalog/probe', 'method' => 'GET'],
|
||||
['path' => '/api/fx-rates/v1/currency-catalog/sync', 'method' => 'POST'],
|
||||
['path' => '/api/fx-rates/v1/settings', 'method' => 'GET'],
|
||||
['path' => '/api/fx-rates/v1/settings', 'method' => 'PUT'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
991
custom/apps/fx-rates/src/Domain/FxRatesService.php
Normal file
991
custom/apps/fx-rates/src/Domain/FxRatesService.php
Normal file
@@ -0,0 +1,991 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Domain;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository;
|
||||
|
||||
final class FxRatesService
|
||||
{
|
||||
private array $memoryCache = [];
|
||||
|
||||
public function __construct(
|
||||
private FxRatesRepository $repository,
|
||||
private array $settings = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function latestStatus(): ?array
|
||||
{
|
||||
return $this->localizeFetch($this->repository->getLatestFetch(null));
|
||||
}
|
||||
|
||||
public function latestStatuses(): array
|
||||
{
|
||||
return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listLatestFetches());
|
||||
}
|
||||
|
||||
public function recentFetches(int $limit = 20): array
|
||||
{
|
||||
return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listRecentFetches($limit));
|
||||
}
|
||||
|
||||
public function snapshot(?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($at === null || trim($at) === '') {
|
||||
$latest = $this->repository->getLatestFetch(null);
|
||||
if ($latest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = $this->repository->getSnapshotByFetchId((int) $latest['id'], null);
|
||||
if ($snapshot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $base, $symbols));
|
||||
}
|
||||
|
||||
$atUtc = $this->normalizeTimestamp($at);
|
||||
if ($atUtc === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nearest = $this->repository->getNearestFetch($base, $atUtc, $windowMinutes);
|
||||
if ($nearest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = $this->repository->getSnapshotByFetchId((int) $nearest['id'], $symbols);
|
||||
if ($snapshot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rebased = $this->rebaseSnapshot($snapshot, $base, $symbols);
|
||||
if ($rebased === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->localizeSnapshot($rebased + [
|
||||
'requested_at' => $atUtc,
|
||||
'distance_seconds' => $nearest['distance_seconds'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
|
||||
{
|
||||
if ($fetchId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
if ($requestedBase === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = $this->repository->getSnapshotByFetchId($fetchId, null);
|
||||
if ($snapshot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $requestedBase, $symbols));
|
||||
}
|
||||
|
||||
public function nearestSnapshot(?string $baseCurrency = null, string $at = '', ?array $symbols = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$timestamp = $this->normalizeTimestamp($at);
|
||||
if ($timestamp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
if ($requestedBase === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nearest = $this->repository->findNearestFetch(null, $timestamp, $windowMinutes);
|
||||
if ($nearest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = $this->repository->getSnapshotByFetchId((int) ($nearest['id'] ?? 0), null);
|
||||
if ($snapshot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rebased = $this->rebaseSnapshot($snapshot, $requestedBase, $symbols);
|
||||
if ($rebased === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->localizeSnapshot($rebased + [
|
||||
'requested_at' => $timestamp,
|
||||
'distance_seconds' => $nearest['distance_seconds'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function findRate(?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$from = $this->normalizeCurrency($fromCurrency);
|
||||
$to = $this->normalizeCurrency($toCurrency);
|
||||
if ($from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($from === $to) {
|
||||
return $this->localizeRateResult([
|
||||
'base_currency' => $from,
|
||||
'target_currency' => $to,
|
||||
'rate' => 1.0,
|
||||
'provider' => 'identity',
|
||||
'fetched_at' => $at ? $this->normalizeTimestamp($at) : null,
|
||||
'rate_date' => $at ? substr((string) $this->normalizeTimestamp($at), 0, 10) : gmdate('Y-m-d'),
|
||||
'is_exact_pair' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$cacheKey = implode(':', [$from, $to, $at ?? '', (string) ($windowMinutes ?? 0)]);
|
||||
if (array_key_exists($cacheKey, $this->memoryCache)) {
|
||||
return $this->memoryCache[$cacheKey];
|
||||
}
|
||||
|
||||
$candidates = array_values(array_unique(array_filter([
|
||||
$this->defaultBaseCurrency(),
|
||||
'EUR',
|
||||
'USD',
|
||||
$from,
|
||||
$to,
|
||||
], static fn (?string $value): bool => is_string($value) && trim($value) !== '')));
|
||||
|
||||
foreach ($candidates as $snapshotBase) {
|
||||
$snapshot = $this->snapshot($snapshotBase, $at, [$from, $to], $windowMinutes);
|
||||
if (!is_array($snapshot)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $from, $to);
|
||||
if ($resolved !== null) {
|
||||
return $this->memoryCache[$cacheKey] = $this->localizeRateResult($resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->memoryCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function convert(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $this->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
if (!is_numeric($rate['rate'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $amount * (float) $rate['rate'];
|
||||
}
|
||||
|
||||
public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null, string $triggerSource = 'manual'): array
|
||||
{
|
||||
$requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
$requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $requestedBase);
|
||||
$payload = $this->fetchLatestPayload($requestedBase, $requestedCurrencies);
|
||||
$base = $this->normalizeCurrency((string) ($payload['base'] ?? $requestedBase));
|
||||
if ($base === '') {
|
||||
$base = $requestedBase !== '' ? $requestedBase : 'USD';
|
||||
}
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$saved = $this->repository->saveFetch(
|
||||
$base,
|
||||
$this->provider(),
|
||||
$rateDate,
|
||||
$rates,
|
||||
gmdate('Y-m-d H:i:s'),
|
||||
$triggerSource
|
||||
);
|
||||
|
||||
return [
|
||||
'base' => $base,
|
||||
'requested_base' => $requestedBase,
|
||||
'rate_date' => $rateDate,
|
||||
'updated_count' => count($saved['rates'] ?? []),
|
||||
'rates' => $saved['rates'] ?? [],
|
||||
'fetch_id' => isset($saved['fetch']['id']) ? (int) $saved['fetch']['id'] : null,
|
||||
'fetch' => $this->localizeFetch(is_array($saved['fetch'] ?? null) ? $saved['fetch'] : null),
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null, string $triggerSource = 'manual'): array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
$requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $base);
|
||||
$latest = $this->repository->getLatestFetch($base);
|
||||
$maxAgeSeconds = (int) round(max(1.0, $maxAgeHours) * 3600);
|
||||
$fetchedAt = is_array($latest) ? $this->parseStoredUtcTimestamp((string) ($latest['fetched_at'] ?? '')) : null;
|
||||
|
||||
if (
|
||||
$fetchedAt !== null
|
||||
&& (time() - $fetchedAt) <= $maxAgeSeconds
|
||||
&& $this->latestFetchCoversCurrencies($latest, $requestedCurrencies)
|
||||
) {
|
||||
return [
|
||||
'base' => $base,
|
||||
'rate_date' => $latest['rate_date'] ?? null,
|
||||
'updated_count' => 0,
|
||||
'rates' => [],
|
||||
'fetch_id' => isset($latest['id']) ? (int) $latest['id'] : null,
|
||||
'fetch' => $this->localizeFetch($latest),
|
||||
'reused' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->refreshLatestRates($requestedCurrencies, $base, $triggerSource);
|
||||
$result['reused'] = false;
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function autoRefreshLatestRates(?string $baseCurrency = null, ?array $currencies = null, ?int $maxAgeMinutes = null, string $triggerSource = 'api'): array
|
||||
{
|
||||
$minutes = $maxAgeMinutes ?? $this->refreshMaxAgeMinutes();
|
||||
$hours = max(1, $minutes) / 60;
|
||||
return $this->ensureFreshLatestRates($hours, $baseCurrency, $currencies, $triggerSource);
|
||||
}
|
||||
|
||||
public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
|
||||
{
|
||||
$fromCurrency = $this->normalizeCurrency($fromCurrency);
|
||||
$toCurrency = $this->normalizeCurrency($toCurrency);
|
||||
if ($fromCurrency === '' || $toCurrency === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($fromCurrency === $toCurrency) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->crossHistory($fromCurrency, $toCurrency, $from, $to, $limit);
|
||||
}
|
||||
|
||||
public function refreshCurrencyCatalog(): array
|
||||
{
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
$currencies = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
|
||||
$items = [];
|
||||
foreach ($currencies as $code => $name) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
$name = trim((string) $name);
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'synced_count' => count($items),
|
||||
'currencies' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeCurrencyCatalog(): array
|
||||
{
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
return [
|
||||
'ok' => !empty($payload['currencies']),
|
||||
'provider' => $this->provider(),
|
||||
'currencies_count' => is_array($payload['currencies'] ?? null) ? count($payload['currencies']) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeLatestRates(?string $baseCurrency = null): array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
$payload = $this->fetchLatestPayload($base, null);
|
||||
return [
|
||||
'ok' => !empty($payload['rates']),
|
||||
'provider' => $this->provider(),
|
||||
'base' => $base,
|
||||
'rate_count' => is_array($payload['rates'] ?? null) ? count($payload['rates']) : 0,
|
||||
'date' => $payload['date'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveRateFromSnapshot(array $snapshot, array $rates, string $from, string $to): ?array
|
||||
{
|
||||
$base = strtoupper((string) ($snapshot['base_currency'] ?? ''));
|
||||
$rate = null;
|
||||
$isExactPair = false;
|
||||
|
||||
if ($base === $from && is_numeric($rates[$to] ?? null)) {
|
||||
$rate = (float) $rates[$to];
|
||||
$isExactPair = true;
|
||||
} elseif ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
|
||||
$rate = 1 / (float) $rates[$from];
|
||||
$isExactPair = true;
|
||||
} elseif (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
|
||||
$rate = (float) $rates[$to] / (float) $rates[$from];
|
||||
}
|
||||
|
||||
if ($rate === null || $rate <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'base_currency' => $from,
|
||||
'target_currency' => $to,
|
||||
'rate' => $rate,
|
||||
'provider' => $snapshot['provider'] ?? null,
|
||||
'fetched_at' => $snapshot['fetched_at'] ?? null,
|
||||
'rate_date' => $snapshot['rate_date'] ?? null,
|
||||
'snapshot_base_currency' => $base,
|
||||
'distance_seconds' => $snapshot['distance_seconds'] ?? null,
|
||||
'requested_at' => $snapshot['requested_at'] ?? null,
|
||||
'is_exact_pair' => $isExactPair,
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchLatestPayload(string $baseCurrency, ?array $currencies = null): array
|
||||
{
|
||||
$request = $this->buildLatestRequest($baseCurrency, $currencies);
|
||||
if ($request === null) {
|
||||
throw new \RuntimeException('FX-URL oder API-Key fehlt.');
|
||||
}
|
||||
|
||||
$payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'FX-Kurse konnten nicht geladen werden.');
|
||||
|
||||
if ($this->usesApiVersion('v3')) {
|
||||
return $this->normalizeCurrencyApiComLatestPayload($payload, $baseCurrency, $currencies);
|
||||
}
|
||||
|
||||
$resolvedBase = $baseCurrency;
|
||||
$rates = [];
|
||||
if ($this->provider() === 'currencyapi') {
|
||||
$resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? ($payload['query']['base_currency'] ?? 'USD')));
|
||||
if ($resolvedBase === '') {
|
||||
$resolvedBase = 'USD';
|
||||
}
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
|
||||
}
|
||||
foreach ($payload['rates'] as $code => $rate) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
if ($code === '' || $code === $resolvedBase || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
} else {
|
||||
$resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? $baseCurrency));
|
||||
if ($resolvedBase === '') {
|
||||
$resolvedBase = $baseCurrency;
|
||||
}
|
||||
$rawRates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
foreach ($rawRates as $code => $rate) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
if ($code === '' || $code === $resolvedBase || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($currencies) && $currencies !== []) {
|
||||
$wanted = [];
|
||||
foreach ($currencies as $currency) {
|
||||
$currency = $this->normalizeCurrency((string) $currency);
|
||||
if ($currency !== '' && isset($rates[$currency])) {
|
||||
$wanted[$currency] = $rates[$currency];
|
||||
}
|
||||
}
|
||||
$rates = $wanted;
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => $resolvedBase,
|
||||
'date' => $payload['updated'] ?? $payload['date'] ?? null,
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchCurrenciesPayload(): array
|
||||
{
|
||||
$request = $this->buildCurrenciesRequest();
|
||||
if ($request === null) {
|
||||
throw new \RuntimeException('FX-API-Key fehlt.');
|
||||
}
|
||||
|
||||
$payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'Waehrungskatalog konnte nicht geladen werden.');
|
||||
|
||||
if ($this->usesApiVersion('v3')) {
|
||||
return $this->normalizeCurrencyApiComCurrenciesPayload($payload);
|
||||
}
|
||||
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function buildLatestRequest(string $baseCurrency, ?array $currencies = null): ?array
|
||||
{
|
||||
$apiKey = $this->apiKey();
|
||||
if ($this->usesApiVersion('v3')) {
|
||||
if ($apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = [];
|
||||
$normalizedCurrencies = [];
|
||||
foreach ($currencies ?? [] as $currency) {
|
||||
$currency = $this->normalizeCurrency((string) $currency);
|
||||
if ($currency !== '' && $currency !== $baseCurrency) {
|
||||
$normalizedCurrencies[] = $currency;
|
||||
}
|
||||
}
|
||||
if ($normalizedCurrencies !== []) {
|
||||
$query[] = 'currencies=' . rawurlencode(implode(',', array_values(array_unique($normalizedCurrencies))));
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => $this->apiUrl() . '/v3/latest?' . implode('&', $query),
|
||||
'headers' => [
|
||||
'Accept: application/json',
|
||||
'apikey: ' . $apiKey,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->provider() === 'currencyapi') {
|
||||
if ($apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => sprintf('%s/api/v2/rates?output=json&key=%s', $this->apiUrl(), rawurlencode($apiKey)),
|
||||
'headers' => ['Accept: application/json'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency)),
|
||||
'headers' => ['Accept: application/json'],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildCurrenciesRequest(): ?array
|
||||
{
|
||||
$apiKey = $this->apiKey();
|
||||
if ($apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->usesApiVersion('v3')) {
|
||||
return [
|
||||
'url' => $this->currenciesApiUrl() . '/v3/currencies',
|
||||
'headers' => [
|
||||
'Accept: application/json',
|
||||
'apikey: ' . $apiKey,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => sprintf('%s/api/v2/currencies?output=json&key=%s', $this->currenciesApiUrl(), rawurlencode($apiKey)),
|
||||
'headers' => ['Accept: application/json'],
|
||||
];
|
||||
}
|
||||
|
||||
private function requestJson(string $url, array $headers, string $fallbackError): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new \RuntimeException('curl_init ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds(),
|
||||
CURLOPT_HTTPHEADER => $headers !== [] ? $headers : ['Accept: application/json'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
$payload = is_string($response) ? json_decode($response, true) : null;
|
||||
throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], $fallbackError));
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.');
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function normalizeCurrencyApiComLatestPayload(array $payload, string $baseCurrency, ?array $currencies = null): array
|
||||
{
|
||||
$rawRates = is_array($payload['data'] ?? null) ? $payload['data'] : null;
|
||||
if ($rawRates === null) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
|
||||
}
|
||||
|
||||
$resolvedBase = $this->normalizeCurrency((string) ($payload['meta']['base_currency_code'] ?? $payload['base'] ?? $baseCurrency));
|
||||
if ($resolvedBase === '') {
|
||||
$resolvedBase = $baseCurrency;
|
||||
}
|
||||
|
||||
$filter = [];
|
||||
foreach ($currencies ?? [] as $currency) {
|
||||
$currency = $this->normalizeCurrency((string) $currency);
|
||||
if ($currency !== '' && $currency !== $resolvedBase) {
|
||||
$filter[$currency] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
foreach ($rawRates as $code => $rateData) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
if ($code === '' || $code === $resolvedBase) {
|
||||
continue;
|
||||
}
|
||||
if ($filter !== [] && !isset($filter[$code])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = is_array($rateData) ? ($rateData['value'] ?? null) : null;
|
||||
if (!is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rates[$code] = (float) $value;
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => $resolvedBase,
|
||||
'date' => $payload['meta']['last_updated_at'] ?? null,
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
private function rebaseSnapshot(array $snapshot, string $requestedBase, ?array $symbols = null): ?array
|
||||
{
|
||||
$snapshotBase = $this->normalizeCurrency((string) ($snapshot['base_currency'] ?? ''));
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
|
||||
if ($snapshotBase === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($requestedBase === '' || $requestedBase === $snapshotBase) {
|
||||
$filteredRates = $this->filterRates($rates, $symbols);
|
||||
if ($this->symbolsContain($symbols, $requestedBase)) {
|
||||
$filteredRates = [$requestedBase => 1.0] + $filteredRates;
|
||||
}
|
||||
|
||||
return $snapshot + [
|
||||
'base_currency' => $snapshotBase,
|
||||
'rates' => $filteredRates,
|
||||
];
|
||||
}
|
||||
|
||||
$baseRate = $rates[$requestedBase] ?? null;
|
||||
if (!is_numeric($baseRate) || (float) $baseRate <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rebasedRates = [];
|
||||
foreach ($rates as $code => $rate) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
if ($code === '' || $code === $requestedBase || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rebasedRates[$code] = (float) $rate / (float) $baseRate;
|
||||
}
|
||||
|
||||
if ($snapshotBase !== '' && $snapshotBase !== $requestedBase) {
|
||||
$rebasedRates[$snapshotBase] = 1 / (float) $baseRate;
|
||||
}
|
||||
|
||||
$filteredRates = $this->filterRates($rebasedRates, $symbols);
|
||||
if ($this->symbolsContain($symbols, $requestedBase)) {
|
||||
$filteredRates = [$requestedBase => 1.0] + $filteredRates;
|
||||
}
|
||||
|
||||
return $snapshot + [
|
||||
'base_currency' => $requestedBase,
|
||||
'rates' => $filteredRates,
|
||||
'snapshot_base_currency' => $snapshotBase,
|
||||
];
|
||||
}
|
||||
|
||||
private function filterRates(array $rates, ?array $symbols = null): array
|
||||
{
|
||||
if (!is_array($symbols) || $symbols === []) {
|
||||
ksort($rates);
|
||||
return $rates;
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
foreach ($symbols as $symbol) {
|
||||
$symbol = $this->normalizeCurrency((string) $symbol);
|
||||
if ($symbol !== '' && isset($rates[$symbol])) {
|
||||
$filtered[$symbol] = (float) $rates[$symbol];
|
||||
}
|
||||
}
|
||||
|
||||
ksort($filtered);
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private function symbolsContain(?array $symbols, string $currency): bool
|
||||
{
|
||||
if (!is_array($symbols) || $symbols === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($symbols as $symbol) {
|
||||
if ($this->normalizeCurrency((string) $symbol) === $currency) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function crossHistory(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
|
||||
{
|
||||
$fromAt = $this->normalizeTimestamp($from);
|
||||
$toAt = $this->normalizeTimestamp($to);
|
||||
$candidates = $this->repository->listRecentFetches(max($limit * 4, 50));
|
||||
$result = [];
|
||||
|
||||
foreach ($candidates as $fetch) {
|
||||
$fetchedAt = (string) ($fetch['fetched_at'] ?? '');
|
||||
if ($fetchedAt === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($fromAt !== null && strcmp($fetchedAt, $fromAt) < 0) {
|
||||
continue;
|
||||
}
|
||||
if ($toAt !== null && strcmp($fetchedAt, $toAt) > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $this->repository->getSnapshotByFetchId((int) ($fetch['id'] ?? 0), [$fromCurrency, $toCurrency]);
|
||||
if ($snapshot === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $fromCurrency, $toCurrency);
|
||||
if ($resolved === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = $this->localizeRateResult($resolved + [
|
||||
'fetch_id' => $fetch['id'] ?? null,
|
||||
]);
|
||||
|
||||
if (count($result) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function localizeFetch(?array $fetch): ?array
|
||||
{
|
||||
if (!is_array($fetch)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fetch['fetched_at_display'] = $this->formatDisplayTimestamp($fetch['fetched_at'] ?? null);
|
||||
$fetch['created_at_display'] = $this->formatDisplayTimestamp($fetch['created_at'] ?? null);
|
||||
$fetch['trigger_source_label'] = $this->triggerSourceLabel((string) ($fetch['trigger_source'] ?? 'manual'));
|
||||
return $fetch;
|
||||
}
|
||||
|
||||
private function localizeSnapshot(?array $snapshot): ?array
|
||||
{
|
||||
if (!is_array($snapshot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot['fetched_at_display'] = $this->formatDisplayTimestamp($snapshot['fetched_at'] ?? null);
|
||||
if (array_key_exists('requested_at', $snapshot)) {
|
||||
$snapshot['requested_at_display'] = $this->formatDisplayTimestamp($snapshot['requested_at']);
|
||||
}
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
private function localizeRateResult(array $rate): array
|
||||
{
|
||||
$rate['fetched_at_display'] = $this->formatDisplayTimestamp($rate['fetched_at'] ?? null);
|
||||
if (array_key_exists('requested_at', $rate)) {
|
||||
$rate['requested_at_display'] = $this->formatDisplayTimestamp($rate['requested_at']);
|
||||
}
|
||||
return $rate;
|
||||
}
|
||||
|
||||
private function formatDisplayTimestamp(mixed $value): string
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new DateTimeZone('UTC'));
|
||||
if (!$date instanceof DateTimeImmutable) {
|
||||
$date = new DateTimeImmutable($raw, new DateTimeZone('UTC'));
|
||||
}
|
||||
return $date->setTimezone($this->displayTimezone())->format('d.m.Y H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCurrencyApiComCurrenciesPayload(array $payload): array
|
||||
{
|
||||
$rawCurrencies = is_array($payload['data'] ?? null) ? $payload['data'] : null;
|
||||
if ($rawCurrencies === null) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
|
||||
}
|
||||
|
||||
$currencies = [];
|
||||
foreach ($rawCurrencies as $code => $currencyData) {
|
||||
$normalizedCode = $this->normalizeCurrency((string) $code);
|
||||
if ($normalizedCode === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = '';
|
||||
if (is_array($currencyData)) {
|
||||
$name = trim((string) ($currencyData['name'] ?? $currencyData['name_plural'] ?? $currencyData['code'] ?? ''));
|
||||
}
|
||||
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currencies[$normalizedCode] = $name;
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'currencies' => $currencies,
|
||||
];
|
||||
}
|
||||
|
||||
private function extractProviderError(array $payload, string $fallback): string
|
||||
{
|
||||
$error = $payload['error'] ?? null;
|
||||
if (is_array($error)) {
|
||||
foreach (['message', 'info', 'code'] as $field) {
|
||||
$value = $error[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$errors = $payload['errors'] ?? null;
|
||||
if (is_array($errors)) {
|
||||
foreach ($errors as $entry) {
|
||||
if (is_string($entry) && trim($entry) !== '') {
|
||||
return trim($entry);
|
||||
}
|
||||
if (is_array($entry)) {
|
||||
foreach (['message', 'detail', 'title'] as $field) {
|
||||
$value = $entry[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['error', 'message', 'msg'] as $field) {
|
||||
$value = $payload[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function normalizeCurrency(?string $currency): string
|
||||
{
|
||||
return strtoupper(trim((string) $currency));
|
||||
}
|
||||
|
||||
private function normalizeRequestedCurrencies(?array $currencies, string $baseCurrency): ?array
|
||||
{
|
||||
if (!is_array($currencies)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$base = $this->normalizeCurrency($baseCurrency);
|
||||
$normalized = array_values(array_unique(array_filter(array_map(
|
||||
fn (mixed $currency): string => $this->normalizeCurrency((string) $currency),
|
||||
$currencies
|
||||
), fn (string $currency): bool => $currency !== '' && $currency !== $base)));
|
||||
|
||||
return $normalized === [] ? null : $normalized;
|
||||
}
|
||||
|
||||
private function latestFetchCoversCurrencies(?array $latestFetch, ?array $currencies): bool
|
||||
{
|
||||
if (!is_array($latestFetch) || !is_numeric($latestFetch['id'] ?? null) || !is_array($currencies) || $currencies === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$snapshot = $this->repository->getSnapshotByFetchId((int) $latestFetch['id'], $currencies);
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
foreach ($currencies as $currency) {
|
||||
if (!array_key_exists($currency, $rates)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function normalizeTimestamp(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $value) === 1) {
|
||||
$date = new DateTimeImmutable(str_replace(' ', 'T', $value), new DateTimeZone('UTC'));
|
||||
} else {
|
||||
$date = new DateTimeImmutable($value);
|
||||
}
|
||||
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function parseStoredUtcTimestamp(string $value): ?int
|
||||
{
|
||||
$normalized = $this->normalizeTimestamp($value);
|
||||
if ($normalized === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new DateTimeImmutable($normalized, new DateTimeZone('UTC')))->getTimestamp();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeRateDate(mixed $value): string
|
||||
{
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false) {
|
||||
return gmdate('Y-m-d', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return gmdate('Y-m-d', (int) $value);
|
||||
}
|
||||
|
||||
return gmdate('Y-m-d');
|
||||
}
|
||||
|
||||
private function provider(): string
|
||||
{
|
||||
$provider = strtolower(trim((string) ($this->settings['provider'] ?? 'currencyapi')));
|
||||
return $provider !== '' ? $provider : 'currencyapi';
|
||||
}
|
||||
|
||||
private function apiUrl(): string
|
||||
{
|
||||
return rtrim((string) ($this->settings['api_url'] ?? 'https://currencyapi.net'), '/');
|
||||
}
|
||||
|
||||
private function currenciesApiUrl(): string
|
||||
{
|
||||
return rtrim((string) ($this->settings['currencies_url'] ?? $this->apiUrl()), '/');
|
||||
}
|
||||
|
||||
private function apiVersion(): string
|
||||
{
|
||||
$version = strtolower(trim((string) ($this->settings['api_version'] ?? 'v2')));
|
||||
return in_array($version, ['v2', 'v3'], true) ? $version : 'v2';
|
||||
}
|
||||
|
||||
private function usesApiVersion(string $version): bool
|
||||
{
|
||||
return $this->apiVersion() === strtolower(trim($version));
|
||||
}
|
||||
|
||||
private function apiKey(): string
|
||||
{
|
||||
return trim((string) ($this->settings['api_key'] ?? ''));
|
||||
}
|
||||
|
||||
private function timeoutSeconds(): int
|
||||
{
|
||||
return max(2, (int) ($this->settings['timeout_sec'] ?? 10));
|
||||
}
|
||||
|
||||
private function refreshMaxAgeMinutes(): int
|
||||
{
|
||||
return max(1, (int) ($this->settings['refresh_max_age_minutes'] ?? 360));
|
||||
}
|
||||
|
||||
private function defaultBaseCurrency(): string
|
||||
{
|
||||
return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
|
||||
}
|
||||
|
||||
private function displayTimezone(): DateTimeZone
|
||||
{
|
||||
$timezone = trim((string) ($this->settings['schedule_timezone'] ?? nexus_display_timezone_name()));
|
||||
try {
|
||||
return new DateTimeZone($timezone);
|
||||
} catch (\Throwable) {
|
||||
return new DateTimeZone(nexus_display_timezone_name());
|
||||
}
|
||||
}
|
||||
|
||||
private function triggerSourceLabel(string $source): string
|
||||
{
|
||||
return match (strtolower(trim($source))) {
|
||||
'cron' => 'Cron',
|
||||
'api' => 'API',
|
||||
'widget' => 'Widget',
|
||||
default => 'Manuell',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
use App\PdoFactory;
|
||||
use RuntimeException;
|
||||
use PDO;
|
||||
|
||||
final class ConnectionFactory
|
||||
{
|
||||
public static function make(SettingsStore $settingsStore): PDO
|
||||
{
|
||||
$settings = $settingsStore->load();
|
||||
$useSeparateDb = !empty($settings['use_separate_db']);
|
||||
|
||||
if ($useSeparateDb) {
|
||||
$dbConfig = is_array($settings['db'] ?? null) ? $settings['db'] : [];
|
||||
if ($dbConfig === []) {
|
||||
throw new RuntimeException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.');
|
||||
}
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
return PdoFactory::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
$dbConfig = app()->config()->dbConfig;
|
||||
if ($dbConfig === []) {
|
||||
throw new RuntimeException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.');
|
||||
}
|
||||
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
return PdoFactory::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
private static function assertSupportedDriver(array $dbConfig): void
|
||||
{
|
||||
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
|
||||
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql', 'sqlite'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:') && !str_starts_with($driver, 'sqlite:')) {
|
||||
throw new RuntimeException('FX-Rates unterstuetzt aktuell MySQL/MariaDB, PostgreSQL und SQLite.');
|
||||
}
|
||||
}
|
||||
}
|
||||
547
custom/apps/fx-rates/src/Infrastructure/FxRatesRepository.php
Normal file
547
custom/apps/fx-rates/src/Infrastructure/FxRatesRepository.php
Normal file
@@ -0,0 +1,547 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class FxRatesRepository
|
||||
{
|
||||
private string $driver;
|
||||
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
private string $tablePrefix = 'fxrate_'
|
||||
) {
|
||||
$this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
|
||||
}
|
||||
|
||||
public function ensureSchema(): void
|
||||
{
|
||||
$fetchTable = $this->table('fetches');
|
||||
$rateTable = $this->table('rates');
|
||||
|
||||
if ($this->driver === 'pgsql') {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$this->pdo->exec("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value NUMERIC(20,10) NOT NULL
|
||||
)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'mysql') {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id),
|
||||
KEY {$fetchTable}_rate_date_idx (rate_date)
|
||||
)");
|
||||
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
fetch_id INTEGER NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY {$rateTable}_fetch_idx (fetch_id),
|
||||
KEY {$rateTable}_currency_idx (currency_code)
|
||||
)");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fetch_id INTEGER NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL
|
||||
)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
|
||||
}
|
||||
|
||||
public function getLatestFetch(?string $baseCurrency = null): ?array
|
||||
{
|
||||
$sql = 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches');
|
||||
$params = [];
|
||||
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
|
||||
$sql .= ' WHERE base_currency = :base_currency';
|
||||
$params['base_currency'] = strtoupper(trim($baseCurrency));
|
||||
}
|
||||
$sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
public function listLatestFetches(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
ORDER BY fetched_at DESC, id DESC'
|
||||
);
|
||||
|
||||
$latestByBase = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$base = strtoupper(trim((string) ($row['base_currency'] ?? '')));
|
||||
if ($base === '' || isset($latestByBase[$base])) {
|
||||
continue;
|
||||
}
|
||||
$latestByBase[$base] = $this->normalizeFetch($row);
|
||||
}
|
||||
|
||||
ksort($latestByBase);
|
||||
return array_values($latestByBase);
|
||||
}
|
||||
|
||||
public function listRecentFetches(int $limit = 20): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
ORDER BY fetched_at DESC, id DESC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(
|
||||
fn (array $row): array => $this->normalizeFetch($row),
|
||||
$stmt->fetchAll(PDO::FETCH_ASSOC) ?: []
|
||||
);
|
||||
}
|
||||
|
||||
public function getSnapshotByFetchId(int $fetchId, ?array $symbols = null): ?array
|
||||
{
|
||||
$fetch = $this->getFetchById($fetchId);
|
||||
if ($fetch === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fetch + [
|
||||
'rates' => $this->ratesForFetch($fetchId, $symbols),
|
||||
];
|
||||
}
|
||||
|
||||
public function findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$targetTs = strtotime($timestamp);
|
||||
if ($targetTs === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
|
||||
return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes);
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach (['<=', '>='] as $operator) {
|
||||
$order = $operator === '<=' ? 'DESC' : 'ASC';
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE fetched_at ' . $operator . ' :target_at
|
||||
ORDER BY fetched_at ' . $order . ', id ' . $order . '
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['target_at' => $timestamp]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (is_array($row)) {
|
||||
$candidate = $this->normalizeFetch($row);
|
||||
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
|
||||
if ($candidateTs !== false) {
|
||||
$candidate['distance_seconds'] = abs($candidateTs - $targetTs);
|
||||
$candidates[] = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($candidates, static fn (array $left, array $right): int => ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX)));
|
||||
$selected = $candidates[0];
|
||||
if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
if ($baseCurrency === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$before = $this->findNeighborFetch($baseCurrency, $timestamp, '<=');
|
||||
$after = $this->findNeighborFetch($baseCurrency, $timestamp, '>=');
|
||||
$targetTs = strtotime($timestamp);
|
||||
if ($targetTs === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$selected = null;
|
||||
$selectedDiff = null;
|
||||
foreach ([$before, $after] as $candidate) {
|
||||
if (!is_array($candidate)) {
|
||||
continue;
|
||||
}
|
||||
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
|
||||
if ($candidateTs === false) {
|
||||
continue;
|
||||
}
|
||||
$diffSeconds = abs($candidateTs - $targetTs);
|
||||
if ($selected === null || $diffSeconds < (int) $selectedDiff) {
|
||||
$selected = $candidate;
|
||||
$selectedDiff = $diffSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
if ($selected === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($windowMinutes !== null && $windowMinutes > 0 && $selectedDiff !== null && $selectedDiff > ($windowMinutes * 60)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $selected + ['distance_seconds' => $selectedDiff];
|
||||
}
|
||||
|
||||
public function listDirectHistory(string $baseCurrency, string $targetCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
|
||||
{
|
||||
$sql = 'SELECT
|
||||
r.id,
|
||||
f.id AS fetch_id,
|
||||
f.base_currency,
|
||||
r.currency_code AS target_currency,
|
||||
r.current_value AS rate,
|
||||
f.rate_date,
|
||||
f.provider,
|
||||
f.fetched_at
|
||||
FROM ' . $this->table('rates') . ' r
|
||||
INNER JOIN ' . $this->table('fetches') . ' f ON f.id = r.fetch_id
|
||||
WHERE f.base_currency = :base_currency
|
||||
AND r.currency_code = :target_currency';
|
||||
$params = [
|
||||
'base_currency' => strtoupper(trim($baseCurrency)),
|
||||
'target_currency' => strtoupper(trim($targetCurrency)),
|
||||
];
|
||||
|
||||
if ($from !== null && trim($from) !== '') {
|
||||
$sql .= ' AND f.fetched_at >= :from_at';
|
||||
$params['from_at'] = $from;
|
||||
}
|
||||
if ($to !== null && trim($to) !== '') {
|
||||
$sql .= ' AND f.fetched_at <= :to_at';
|
||||
$params['to_at'] = $to;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY f.fetched_at DESC, r.id DESC LIMIT :limit';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue(':' . $key, $value);
|
||||
}
|
||||
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
|
||||
}
|
||||
|
||||
public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null, string $triggerSource = 'manual'): array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
$provider = trim($provider) !== '' ? trim($provider) : 'currencyapi';
|
||||
$fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s');
|
||||
$triggerSource = $this->normalizeTriggerSource($triggerSource);
|
||||
$normalizedRates = [];
|
||||
foreach ($rates as $currencyCode => $rate) {
|
||||
$currencyCode = strtoupper(trim((string) $currencyCode));
|
||||
if ($currencyCode === '' || $currencyCode === $baseCurrency || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$normalizedRates[$currencyCode] = (float) $rate;
|
||||
}
|
||||
|
||||
$startedTransaction = false;
|
||||
if (!$this->pdo->inTransaction()) {
|
||||
$this->pdo->beginTransaction();
|
||||
$startedTransaction = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->driver === 'pgsql') {
|
||||
$fetchStmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('fetches') . ' (
|
||||
provider, trigger_source, base_currency, rate_date, fetched_at
|
||||
) VALUES (
|
||||
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
|
||||
)
|
||||
RETURNING *'
|
||||
);
|
||||
$fetchStmt->execute([
|
||||
'provider' => $provider,
|
||||
'trigger_source' => $triggerSource,
|
||||
'base_currency' => $baseCurrency,
|
||||
'rate_date' => $rateDate,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$fetch = $this->normalizeFetch($fetchStmt->fetch(PDO::FETCH_ASSOC) ?: []);
|
||||
} else {
|
||||
$fetchStmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('fetches') . ' (
|
||||
provider, trigger_source, base_currency, rate_date, fetched_at
|
||||
) VALUES (
|
||||
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
|
||||
)'
|
||||
);
|
||||
$fetchStmt->execute([
|
||||
'provider' => $provider,
|
||||
'trigger_source' => $triggerSource,
|
||||
'base_currency' => $baseCurrency,
|
||||
'rate_date' => $rateDate,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$fetch = $this->getFetchById((int) $this->pdo->lastInsertId()) ?? [];
|
||||
}
|
||||
|
||||
$savedRates = [];
|
||||
if ($normalizedRates !== []) {
|
||||
$placeholders = [];
|
||||
$params = ['fetch_id' => (int) ($fetch['id'] ?? 0)];
|
||||
$index = 0;
|
||||
foreach ($normalizedRates as $currencyCode => $rate) {
|
||||
$codeKey = 'currency_code_' . $index;
|
||||
$valueKey = 'current_value_' . $index;
|
||||
$placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})";
|
||||
$params[$codeKey] = $currencyCode;
|
||||
$params[$valueKey] = $rate;
|
||||
$savedRates[] = [
|
||||
'fetch_id' => $fetch['id'] ?? null,
|
||||
'base_currency' => $baseCurrency,
|
||||
'target_currency' => $currencyCode,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $provider,
|
||||
'fetched_at' => $fetchedAt,
|
||||
];
|
||||
$index++;
|
||||
}
|
||||
|
||||
$insert = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders)
|
||||
);
|
||||
$insert->execute($params);
|
||||
}
|
||||
|
||||
if ($startedTransaction) {
|
||||
$this->pdo->commit();
|
||||
}
|
||||
|
||||
return [
|
||||
'fetch' => $fetch,
|
||||
'rates' => $savedRates,
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
if ($startedTransaction && $this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
$fetchedAt = trim($fetchedAt);
|
||||
if ($baseCurrency === '' || $fetchedAt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE base_currency = :base_currency
|
||||
AND fetched_at = :fetched_at
|
||||
ORDER BY id ASC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'base_currency' => $baseCurrency,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function getFetchById(int $fetchId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $fetchId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function findNeighborFetch(string $baseCurrency, string $timestamp, string $operator): ?array
|
||||
{
|
||||
$order = $operator === '<=' ? 'DESC' : 'ASC';
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE base_currency = :base_currency
|
||||
AND fetched_at ' . $operator . ' :target_at
|
||||
ORDER BY fetched_at ' . $order . ', id ' . $order . '
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'base_currency' => $baseCurrency,
|
||||
'target_at' => $timestamp,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function ratesForFetch(int $fetchId, ?array $symbols = null): array
|
||||
{
|
||||
$sql = 'SELECT currency_code, current_value FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id';
|
||||
$params = ['fetch_id' => $fetchId];
|
||||
|
||||
$normalizedSymbols = [];
|
||||
if (is_array($symbols)) {
|
||||
foreach ($symbols as $symbol) {
|
||||
$symbol = strtoupper(trim((string) $symbol));
|
||||
if ($symbol !== '') {
|
||||
$normalizedSymbols[] = $symbol;
|
||||
}
|
||||
}
|
||||
$normalizedSymbols = array_values(array_unique($normalizedSymbols));
|
||||
}
|
||||
|
||||
if ($normalizedSymbols !== []) {
|
||||
$placeholders = [];
|
||||
foreach ($normalizedSymbols as $index => $symbol) {
|
||||
$key = 'symbol_' . $index;
|
||||
$placeholders[] = ':' . $key;
|
||||
$params[$key] = $symbol;
|
||||
}
|
||||
$sql .= ' AND currency_code IN (' . implode(', ', $placeholders) . ')';
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY currency_code ASC';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$rates = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$code = strtoupper(trim((string) ($row['currency_code'] ?? '')));
|
||||
$rate = $row['current_value'] ?? null;
|
||||
if ($code === '' || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
|
||||
private function normalizeFetch(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => isset($row['id']) ? (int) $row['id'] : null,
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'),
|
||||
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
|
||||
'rate_date' => (string) ($row['rate_date'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureColumn(string $table, string $column, string $alterSql): void
|
||||
{
|
||||
try {
|
||||
$stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1');
|
||||
if ($stmt instanceof \PDOStatement) {
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pdo->exec($alterSql);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeTriggerSource(string $source): string
|
||||
{
|
||||
$source = strtolower(trim($source));
|
||||
return match ($source) {
|
||||
'cron', 'manual', 'api', 'migration', 'widget' => $source,
|
||||
default => 'manual',
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeRate(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => isset($row['id']) ? (int) $row['id'] : null,
|
||||
'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null,
|
||||
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
|
||||
'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')),
|
||||
'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null,
|
||||
'rate_date' => (string) ($row['rate_date'] ?? ''),
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function table(string $logicalName): string
|
||||
{
|
||||
return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName);
|
||||
}
|
||||
}
|
||||
38
custom/apps/fx-rates/src/Infrastructure/ModuleConfig.php
Normal file
38
custom/apps/fx-rates/src/Infrastructure/ModuleConfig.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
final class ModuleConfig
|
||||
{
|
||||
public function __construct(
|
||||
private array $config
|
||||
) {
|
||||
}
|
||||
|
||||
public static function load(string $moduleBasePath): self
|
||||
{
|
||||
$config = require $moduleBasePath . '/config/module.php';
|
||||
return new self(is_array($config) ? $config : []);
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function useProjectDatabase(): bool
|
||||
{
|
||||
return (bool) ($this->config['use_project_database'] ?? true);
|
||||
}
|
||||
|
||||
public function tablePrefix(): string
|
||||
{
|
||||
return (string) ($this->config['table_prefix'] ?? 'fxrate_');
|
||||
}
|
||||
|
||||
public function settingsFile(): string
|
||||
{
|
||||
return (string) ($this->config['settings_file'] ?? dirname(__DIR__, 4) . '/data/fx-rates/settings.json');
|
||||
}
|
||||
}
|
||||
118
custom/apps/fx-rates/src/Infrastructure/SettingsStore.php
Normal file
118
custom/apps/fx-rates/src/Infrastructure/SettingsStore.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
final class SettingsStore
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleConfig $moduleConfig
|
||||
) {
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$defaults = $this->normalize($this->moduleConfig->all());
|
||||
$file = $this->settingsFile();
|
||||
if (!is_file($file)) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($file);
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
$saved = is_array($decoded) ? $decoded : [];
|
||||
|
||||
return $this->normalize(array_replace_recursive($defaults, $saved));
|
||||
}
|
||||
|
||||
public function save(array $patch): array
|
||||
{
|
||||
$current = $this->load();
|
||||
$next = $this->normalize(array_replace_recursive($current, $patch));
|
||||
$next['updated_at'] = gmdate(DATE_ATOM);
|
||||
|
||||
$file = $this->settingsFile();
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
file_put_contents($file, json_encode($next, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
private function settingsFile(): string
|
||||
{
|
||||
return $this->moduleConfig->settingsFile();
|
||||
}
|
||||
|
||||
private function normalize(array $settings): array
|
||||
{
|
||||
$preferredCurrencies = $settings['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE', 'BTC'];
|
||||
if (is_string($preferredCurrencies)) {
|
||||
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
|
||||
}
|
||||
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): string => strtoupper(trim((string) $value)),
|
||||
is_array($preferredCurrencies) ? $preferredCurrencies : []
|
||||
))));
|
||||
|
||||
$catalog = $settings['currency_catalog'] ?? [];
|
||||
$catalog = array_values(array_filter(array_map(static function (mixed $item): ?array {
|
||||
if (!is_array($item)) {
|
||||
return null;
|
||||
}
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
];
|
||||
}, is_array($catalog) ? $catalog : [])));
|
||||
|
||||
$refreshHours = (float) ($settings['refresh_max_age_hours'] ?? 6);
|
||||
if ($refreshHours <= 0) {
|
||||
$refreshHours = 6;
|
||||
}
|
||||
|
||||
$displayBase = strtoupper(trim((string) ($settings['display_base_currency'] ?? ($settings['default_base_currency'] ?? 'EUR'))));
|
||||
$defaultBase = strtoupper(trim((string) ($settings['default_base_currency'] ?? 'EUR')));
|
||||
if ($defaultBase === '') {
|
||||
$defaultBase = 'EUR';
|
||||
}
|
||||
if ($displayBase === '') {
|
||||
$displayBase = $defaultBase;
|
||||
}
|
||||
|
||||
return [
|
||||
'use_separate_db' => !empty($settings['use_separate_db']),
|
||||
'db' => is_array($settings['db'] ?? null) ? $settings['db'] : [],
|
||||
'provider' => trim((string) ($settings['provider'] ?? 'currencyapi')) ?: 'currencyapi',
|
||||
'api_version' => in_array(strtolower(trim((string) ($settings['api_version'] ?? 'v2'))), ['v2', 'v3'], true)
|
||||
? strtolower(trim((string) ($settings['api_version'] ?? 'v2')))
|
||||
: 'v2',
|
||||
'api_url' => rtrim((string) ($settings['api_url'] ?? 'https://currencyapi.net'), '/'),
|
||||
'currencies_url' => rtrim((string) ($settings['currencies_url'] ?? ($settings['api_url'] ?? 'https://currencyapi.net')), '/'),
|
||||
'api_key' => trim((string) ($settings['api_key'] ?? '')),
|
||||
'timeout_sec' => max(2, (int) ($settings['timeout_sec'] ?? 10)),
|
||||
'refresh_max_age_hours' => $refreshHours,
|
||||
'refresh_max_age_minutes' => max(1, (int) round($refreshHours * 60)),
|
||||
'default_base_currency' => $defaultBase,
|
||||
'display_base_currency' => $displayBase,
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
'currency_catalog' => $catalog,
|
||||
'currency_catalog_synced_at' => trim((string) ($settings['currency_catalog_synced_at'] ?? '')),
|
||||
'schedule_timezone' => trim((string) ($settings['schedule_timezone'] ?? nexus_display_timezone_name())) ?: nexus_display_timezone_name(),
|
||||
'updated_at' => trim((string) ($settings['updated_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
1
custom/apps/fx-rates/storage/.gitkeep
Normal file
1
custom/apps/fx-rates/storage/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
17
custom/apps/mining-checker/api/index.php
Normal file
17
custom/apps/mining-checker/api/index.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
|
||||
$prefix = '/api/mining-checker/index.php/';
|
||||
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
|
||||
|
||||
if ($relativePath === '') {
|
||||
$relativePath = str_starts_with($requestPath, $prefix)
|
||||
? substr($requestPath, strlen($prefix))
|
||||
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
|
||||
}
|
||||
|
||||
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($relativePath);
|
||||
0
custom/apps/mining-checker/assets/css/.gitkeep
Normal file
0
custom/apps/mining-checker/assets/css/.gitkeep
Normal file
828
custom/apps/mining-checker/assets/css/app.css
Normal file
828
custom/apps/mining-checker/assets/css/app.css
Normal file
@@ -0,0 +1,828 @@
|
||||
#mining-checker-app {
|
||||
--mc-surface: #ffffff;
|
||||
--mc-surface-strong: #ffffff;
|
||||
--mc-line: rgba(148, 163, 184, 0.24);
|
||||
--mc-line-strong: rgba(37, 99, 235, 0.24);
|
||||
--mc-text: #0f172a;
|
||||
--mc-text-muted: #475569;
|
||||
--mc-accent: #2563eb;
|
||||
--mc-accent-strong: #1d4ed8;
|
||||
--mc-danger: #d92d20;
|
||||
--mc-success: #14804a;
|
||||
--mc-warning: #b54708;
|
||||
min-height: 0;
|
||||
background: #f8fafc;
|
||||
color: var(--mc-text);
|
||||
font-family: inherit;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#mining-checker-app,
|
||||
#mining-checker-app * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-grid-bg {
|
||||
background: none;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-sidebar-title {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-nav-meta {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: var(--mc-text-muted);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-panel {
|
||||
border: 1px solid var(--mc-line);
|
||||
background: var(--mc-surface);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-row--wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-stat-card,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-target-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell,
|
||||
#mining-checker-app .mc-display-field {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
background: var(--mc-surface);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-top,
|
||||
#mining-checker-app .mc-inline-row,
|
||||
#mining-checker-app .mc-flex-split,
|
||||
#mining-checker-app .mc-section-head {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-top,
|
||||
#mining-checker-app .mc-flex-split,
|
||||
#mining-checker-app .mc-section-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-copy,
|
||||
#mining-checker-app .mc-hero-controls,
|
||||
#mining-checker-app .mc-panel-body,
|
||||
#mining-checker-app .mc-form,
|
||||
#mining-checker-app .mc-field,
|
||||
#mining-checker-app .mc-filter-grid,
|
||||
#mining-checker-app .mc-chart,
|
||||
#mining-checker-app .mc-target-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-filter-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-text,
|
||||
#mining-checker-app p,
|
||||
#mining-checker-app td,
|
||||
#mining-checker-app th,
|
||||
#mining-checker-app label,
|
||||
#mining-checker-app summary,
|
||||
#mining-checker-app pre {
|
||||
color: var(--mc-text-muted);
|
||||
}
|
||||
|
||||
#mining-checker-app h1,
|
||||
#mining-checker-app h2,
|
||||
#mining-checker-app h3 {
|
||||
margin: 0;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stats-grid,
|
||||
#mining-checker-app .mc-target-grid,
|
||||
#mining-checker-app .mc-overview-grid,
|
||||
#mining-checker-app .mc-two-col,
|
||||
#mining-checker-app .mc-main-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-overview-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-target-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-two-col {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-card {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-balance {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-history-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-history-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
background: var(--mc-surface);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-history-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-history-asset-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-history-asset-card strong {
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-row strong {
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-main-grid {
|
||||
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-card,
|
||||
#mining-checker-app .mc-target-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-card {
|
||||
background: var(--mc-surface-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-kicker {
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
border: 1px solid var(--mc-line-strong);
|
||||
background: #eff6ff;
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--warn {
|
||||
background: color-mix(in srgb, var(--mc-warning) 12%, transparent);
|
||||
color: var(--mc-warning);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--info {
|
||||
background: #eff6ff;
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--danger {
|
||||
background: color-mix(in srgb, var(--mc-danger) 12%, transparent);
|
||||
color: var(--mc-danger);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--success {
|
||||
background: color-mix(in srgb, var(--mc-success) 12%, transparent);
|
||||
color: var(--mc-success);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button,
|
||||
#mining-checker-app button,
|
||||
#mining-checker-app input,
|
||||
#mining-checker-app select,
|
||||
#mining-checker-app textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
padding: 11px 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--primary {
|
||||
background: var(--mc-accent);
|
||||
color: #f8fafc;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
#mining-checker-app .mc-button--secondary {
|
||||
background: #ffffff;
|
||||
border-color: #cbd5e1;
|
||||
color: var(--mc-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--danger {
|
||||
background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92));
|
||||
color: #fff7f7;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--ghost {
|
||||
background: #eff6ff;
|
||||
border-color: rgba(37, 99, 235, 0.18);
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-console {
|
||||
border-color: rgba(125, 211, 252, 0.28);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-view-switch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-log {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-text-console {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-entry {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-field {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-field-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--mc-text-muted);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input,
|
||||
#mining-checker-app .mc-select,
|
||||
#mining-checker-app .mc-textarea,
|
||||
#mining-checker-app .mc-file {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 11px 12px;
|
||||
color: var(--mc-text);
|
||||
background: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-select option {
|
||||
color: #09111f;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input::placeholder,
|
||||
#mining-checker-app .mc-textarea::placeholder {
|
||||
color: #6d7c90;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input:focus,
|
||||
#mining-checker-app .mc-select:focus,
|
||||
#mining-checker-app .mc-textarea:focus,
|
||||
#mining-checker-app .mc-file:focus {
|
||||
border-color: var(--mc-accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields > .mc-field {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--error {
|
||||
border-color: rgba(185, 28, 28, 0.18);
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--warning {
|
||||
border-color: rgba(217, 119, 6, 0.18);
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--success {
|
||||
border-color: rgba(5, 150, 105, 0.18);
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table-shell {
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table th,
|
||||
#mining-checker-app .mc-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table thead {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-empty {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
#mining-checker-app details {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
#mining-checker-app pre {
|
||||
white-space: pre-wrap;
|
||||
margin: 12px 0 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-mini-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-mini-card,
|
||||
#mining-checker-app .mc-display-field {
|
||||
padding: 12px 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-code-block {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-chart svg {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(2, 6, 23, 0.72);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal {
|
||||
width: min(720px, 100%);
|
||||
max-height: min(80vh, 900px);
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-chart path,
|
||||
#mining-checker-app .mc-chart polyline,
|
||||
#mining-checker-app .mc-chart line,
|
||||
#mining-checker-app .mc-chart rect {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
#mining-checker-app .mc-hero-top,
|
||||
#mining-checker-app .mc-inline-row,
|
||||
#mining-checker-app .mc-flex-split,
|
||||
#mining-checker-app .mc-section-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-shell {
|
||||
width: min(100% - 10px, 1360px);
|
||||
padding: 8px 0 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stack {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero,
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-title {
|
||||
font-size: clamp(1.45rem, 8vw, 2.15rem);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-copy {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-home-link {
|
||||
justify-self: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-tabs {
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
margin: 12px -4px 0;
|
||||
padding: 0 4px 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--tab {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table th,
|
||||
#mining-checker-app .mc-table td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal {
|
||||
max-height: min(92vh, 900px);
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#mining-checker-app,
|
||||
#mining-checker-app * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#mining-checker-app {
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-grid-bg {
|
||||
overflow-x: hidden;
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-shell {
|
||||
width: 100%;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stack {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero,
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty {
|
||||
width: 100%;
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero {
|
||||
border-radius: 0 0 18px 18px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-title,
|
||||
#mining-checker-app .mc-hero-copy p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-top {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-kicker {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-tabs {
|
||||
margin: 10px -6px 0;
|
||||
padding: 0 6px 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stats-grid,
|
||||
#mining-checker-app .mc-target-grid,
|
||||
#mining-checker-app .mc-overview-grid,
|
||||
#mining-checker-app .mc-two-col,
|
||||
#mining-checker-app .mc-main-grid,
|
||||
#mining-checker-app .mc-filter-grid,
|
||||
#mining-checker-app .mc-mini-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-card,
|
||||
#mining-checker-app .mc-target-card,
|
||||
#mining-checker-app .mc-mini-card,
|
||||
#mining-checker-app .mc-display-field {
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-value {
|
||||
font-size: clamp(1.45rem, 8vw, 1.85rem);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#mining-checker-app h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
#mining-checker-app h3 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-text,
|
||||
#mining-checker-app p,
|
||||
#mining-checker-app td,
|
||||
#mining-checker-app th,
|
||||
#mining-checker-app label,
|
||||
#mining-checker-app summary,
|
||||
#mining-checker-app pre {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input,
|
||||
#mining-checker-app .mc-select,
|
||||
#mining-checker-app .mc-textarea,
|
||||
#mining-checker-app .mc-file {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields,
|
||||
#mining-checker-app .mc-debug-tools,
|
||||
#mining-checker-app .mc-debug-view-switch {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields > .mc-field {
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge {
|
||||
padding: 7px 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table {
|
||||
max-width: none;
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table th,
|
||||
#mining-checker-app .mc-table td {
|
||||
padding: 9px 10px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-chart svg {
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal-backdrop {
|
||||
align-items: stretch;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 16px);
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
}
|
||||
0
custom/apps/mining-checker/assets/js/.gitkeep
Normal file
0
custom/apps/mining-checker/assets/js/.gitkeep
Normal file
3701
custom/apps/mining-checker/assets/js/app.js
Normal file
3701
custom/apps/mining-checker/assets/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
17
custom/apps/mining-checker/bootstrap.php
Normal file
17
custom/apps/mining-checker/bootstrap.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\MiningChecker\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, strlen($prefix));
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
24
custom/apps/mining-checker/config/example.config.php
Normal file
24
custom/apps/mining-checker/config/example.config.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'MINING_CHECKER_DEFAULT_PROJECT_KEY' => 'doge-main',
|
||||
'MINING_CHECKER_OCR_PROVIDERS' => 'ocrspace,tesseract',
|
||||
'MINING_CHECKER_OCR_SPACE_URL' => 'https://api.ocr.space/parse/image',
|
||||
'MINING_CHECKER_OCR_SPACE_API_KEY' => 'K83150278888957',
|
||||
'MINING_CHECKER_OCR_SPACE_LANGUAGE' => 'eng',
|
||||
'MINING_CHECKER_OCR_SPACE_ENGINE' => '2',
|
||||
'MINING_CHECKER_OCR_SPACE_SCALE' => 'true',
|
||||
'MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION' => 'true',
|
||||
'MINING_CHECKER_OCR_SPACE_IS_TABLE' => 'false',
|
||||
'MINING_CHECKER_OCR_SPACE_TIMEOUT' => '25',
|
||||
'MINING_CHECKER_TESSERACT_BIN' => '/usr/bin/tesseract',
|
||||
'MINING_CHECKER_TESSERACT_LANG' => 'eng',
|
||||
'MINING_CHECKER_FX_PROVIDER' => 'currencyapi',
|
||||
'MINING_CHECKER_FX_URL' => 'https://currencyapi.net',
|
||||
'MINING_CHECKER_FX_CURRENCIES_URL' => 'https://currencyapi.net',
|
||||
'MINING_CHECKER_FX_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1',
|
||||
'MINING_CHECKER_FX_TIMEOUT' => '10',
|
||||
'MINING_CHECKER_FX_CACHE_TTL' => '21600',
|
||||
'MINING_CHECKER_FX_AUTO_FETCH_ON_MISS' => 'false',
|
||||
];
|
||||
46
custom/apps/mining-checker/config/module.php
Normal file
46
custom/apps/mining-checker/config/module.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default_project_key' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
|
||||
'use_project_database' => true,
|
||||
'table_prefix' => 'miningcheck_',
|
||||
'uploads_dir' => dirname(__DIR__, 3) . '/data/mining-checker/uploads',
|
||||
'uploads_public_prefix' => '/data/mining-checker/uploads',
|
||||
'ocr' => [
|
||||
'providers' => array_values(array_filter(array_map(
|
||||
static fn (string $provider): string => trim(strtolower($provider)),
|
||||
explode(',', getenv('MINING_CHECKER_OCR_PROVIDERS') ?: 'ocrspace,tesseract')
|
||||
))),
|
||||
'ocrspace' => [
|
||||
'url' => getenv('MINING_CHECKER_OCR_SPACE_URL') ?: 'https://api.ocr.space/parse/image',
|
||||
'api_key' => getenv('MINING_CHECKER_OCR_SPACE_API_KEY') ?: 'K83150278888957',
|
||||
'language' => getenv('MINING_CHECKER_OCR_SPACE_LANGUAGE') ?: 'eng',
|
||||
'engine' => (int) (getenv('MINING_CHECKER_OCR_SPACE_ENGINE') ?: 2),
|
||||
'scale' => getenv('MINING_CHECKER_OCR_SPACE_SCALE') ?: 'true',
|
||||
'detect_orientation' => getenv('MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION') ?: 'true',
|
||||
'is_table' => getenv('MINING_CHECKER_OCR_SPACE_IS_TABLE') ?: 'false',
|
||||
'timeout' => (int) (getenv('MINING_CHECKER_OCR_SPACE_TIMEOUT') ?: 25),
|
||||
],
|
||||
'tesseract' => [
|
||||
'binary' => getenv('MINING_CHECKER_TESSERACT_BIN') ?: 'tesseract',
|
||||
'language' => getenv('MINING_CHECKER_TESSERACT_LANG') ?: 'eng',
|
||||
],
|
||||
],
|
||||
'fx' => [
|
||||
'provider' => getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi',
|
||||
'url' => getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net',
|
||||
'currencies_url' => getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: 'https://currencyapi.net',
|
||||
'api_key' => getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1',
|
||||
'timeout' => (int) (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10),
|
||||
'cache_ttl' => (int) (getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600),
|
||||
'auto_fetch_on_miss' => filter_var(
|
||||
getenv('MINING_CHECKER_FX_AUTO_FETCH_ON_MISS') ?: 'false',
|
||||
FILTER_VALIDATE_BOOL
|
||||
),
|
||||
],
|
||||
'debug' => [
|
||||
'enabled' => filter_var(getenv('MINING_CHECKER_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL),
|
||||
'dir' => getenv('MINING_CHECKER_DEBUG_DIR') ?: dirname(__DIR__, 3) . '/data/mining-checker/debug',
|
||||
],
|
||||
];
|
||||
17
custom/apps/mining-checker/design.json
Normal file
17
custom/apps/mining-checker/design.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Mining-Checker",
|
||||
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
|
||||
"actions": [
|
||||
{ "label": "Nexus Übersicht", "href": "/modules" },
|
||||
{ "label": "Setup", "href": "/apps/mining-checker?view=setup", "variant": "secondary" }
|
||||
],
|
||||
"sections": [
|
||||
{ "key": "overview", "label": "Übersicht" },
|
||||
{ "key": "upload", "label": "Upload" },
|
||||
{ "key": "measurements", "label": "Mining-History" },
|
||||
{ "key": "wallet", "label": "NC Wallet" },
|
||||
{ "key": "mining", "label": "Miner-Daten" },
|
||||
{ "key": "dashboards", "label": "Dashboards" }
|
||||
]
|
||||
}
|
||||
64
custom/apps/mining-checker/desktop.php
Normal file
64
custom/apps/mining-checker/desktop.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$manifest = [];
|
||||
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
|
||||
if ($manifestRaw !== false) {
|
||||
$decoded = json_decode($manifestRaw, true);
|
||||
$manifest = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$design = [];
|
||||
$designRaw = is_file(__DIR__ . '/design.json') ? file_get_contents(__DIR__ . '/design.json') : false;
|
||||
if ($designRaw !== false) {
|
||||
$decoded = json_decode($designRaw, true);
|
||||
$design = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$sections = array_values(array_filter(
|
||||
is_array($design['sections'] ?? null) ? $design['sections'] : [],
|
||||
static fn (mixed $section): bool => is_array($section)
|
||||
));
|
||||
|
||||
$assetVersion = static function (string $relativePath): string {
|
||||
$fullPath = __DIR__ . '/' . ltrim($relativePath, '/');
|
||||
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
|
||||
|
||||
return $mtime === false ? '0' : (string) $mtime;
|
||||
};
|
||||
|
||||
return [
|
||||
'app_id' => 'mining-checker',
|
||||
'title' => (string) ($manifest['title'] ?? 'Mining-Checker'),
|
||||
'icon' => 'MC',
|
||||
'entry_route' => '/apps/mining-checker',
|
||||
'window_mode' => 'window',
|
||||
'content_mode' => 'native-module',
|
||||
'default_width' => 1180,
|
||||
'default_height' => 760,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => true,
|
||||
'module_name' => 'finance',
|
||||
'summary' => (string) ($manifest['description'] ?? 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.'),
|
||||
'native_module' => [
|
||||
'initializer' => 'initMiningCheckerApp',
|
||||
'options' => [
|
||||
'apiBase' => '/api/mining-checker/index.php?path=v1',
|
||||
'defaultProjectKey' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
|
||||
'activeView' => 'overview',
|
||||
'sections' => $sections,
|
||||
'moduleDebugEnabled' => false,
|
||||
],
|
||||
'assets' => [
|
||||
'styles' => [
|
||||
['href' => '/module-assets/index.php?module=mining-checker&path=assets/css/app.css&v=' . rawurlencode($assetVersion('assets/css/app.css'))],
|
||||
],
|
||||
'scripts' => [
|
||||
['src' => 'https://unpkg.com/react@18/umd/react.production.min.js', 'crossorigin' => true],
|
||||
['src' => 'https://unpkg.com/react-dom@18/umd/react-dom.production.min.js', 'crossorigin' => true],
|
||||
['src' => '/module-assets/index.php?module=mining-checker&path=assets/js/app.js&v=' . rawurlencode($assetVersion('assets/js/app.js'))],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
114
custom/apps/mining-checker/docs/README.md
Normal file
114
custom/apps/mining-checker/docs/README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Mining-Checker Modul
|
||||
|
||||
## Zweck
|
||||
|
||||
Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken.
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```text
|
||||
custom/apps/mining-checker/
|
||||
|-- api/
|
||||
|-- assets/
|
||||
| |-- css/
|
||||
| `-- js/
|
||||
|-- config/
|
||||
|-- docs/
|
||||
|-- pages/
|
||||
|-- partials/
|
||||
|-- sql/
|
||||
| `-- migrations/
|
||||
|-- src/
|
||||
| |-- Api/
|
||||
| |-- Domain/
|
||||
| |-- Infrastructure/
|
||||
| `-- Support/
|
||||
|-- storage/uploads/
|
||||
|-- bootstrap.php
|
||||
`-- module.json
|
||||
```
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
- `GET /api/mining-checker/v1/health`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/measurements`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/measurements`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/settings`
|
||||
- `PUT /api/mining-checker/v1/projects/{projectKey}/settings`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/targets`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/targets`
|
||||
- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/initialize`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate`
|
||||
|
||||
## Integration
|
||||
|
||||
1. SQL aus dem passenden Dialekt-Schema ausfuehren:
|
||||
- MySQL/MariaDB: `sql/schema.mysql.sql`
|
||||
- PostgreSQL: `sql/schema.pgsql.sql`
|
||||
- `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups
|
||||
2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an.
|
||||
3. Modulroute ueber `/module/mining-checker` aufrufen.
|
||||
4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet.
|
||||
|
||||
Hinweis:
|
||||
Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema.
|
||||
Seed-Daten werden dabei nicht automatisch eingespielt.
|
||||
Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt.
|
||||
|
||||
## OCR-Hinweis
|
||||
|
||||
Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen.
|
||||
|
||||
Empfohlene Umgebungsvariablen:
|
||||
|
||||
- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract`
|
||||
- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image`
|
||||
- `MINING_CHECKER_OCR_SPACE_API_KEY=...`
|
||||
- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng`
|
||||
- `MINING_CHECKER_OCR_SPACE_ENGINE=2`
|
||||
- `MINING_CHECKER_OCR_SPACE_SCALE=true`
|
||||
- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true`
|
||||
- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false`
|
||||
- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25`
|
||||
- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract`
|
||||
- `MINING_CHECKER_TESSERACT_LANG=eng`
|
||||
|
||||
Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi
|
||||
|
||||
## Wechselkurse und Waehrungen
|
||||
|
||||
Der Mining-Checker speichert keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`.
|
||||
Auch der Waehrungskatalog und die bevorzugten Waehrungen kommen ausschliesslich aus `fx-rates`. Der Mining-Checker fuehrt keine eigene Waehrungstabelle und keine eigene Alias-Verwaltung mehr im Laufzeitpfad.
|
||||
- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false`
|
||||
|
||||
Optionaler JSON-Body:
|
||||
|
||||
- `base`: Standard `EUR`
|
||||
- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"base": "EUR"
|
||||
}
|
||||
```
|
||||
|
||||
`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`.
|
||||
|
||||
Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet.
|
||||
|
||||
Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert.
|
||||
|
||||
Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot.
|
||||
37
custom/apps/mining-checker/module.json
Normal file
37
custom/apps/mining-checker/module.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "Mining-Checker",
|
||||
"title": "Mining-Checker",
|
||||
"version": "0.3.0",
|
||||
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
|
||||
"enabled_by_default": true,
|
||||
"app_scope": "module",
|
||||
"installable": true,
|
||||
"desktop": {
|
||||
"available": true,
|
||||
"show_on_desktop": true,
|
||||
"show_in_start_menu": true
|
||||
},
|
||||
"setup": {
|
||||
"sections": {
|
||||
"database": true
|
||||
},
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "baseline_measured_at", "label": "Baseline Zeitpunkt", "type": "datetime-local", "required": false, "help": "Referenzzeitpunkt fuer die erste Baseline-Messung." },
|
||||
{ "name": "baseline_coins_total", "label": "Baseline Coins", "type": "number", "required": false, "help": "Coin-Bestand zum Baseline-Zeitpunkt." },
|
||||
{ "name": "report_currency", "label": "Standard-Berichtswaehrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Kennzahlen und Berichte, z.B. EUR." }
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
103
custom/apps/mining-checker/pages/index.php
Normal file
103
custom/apps/mining-checker/pages/index.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
ModuleHttp::requireDesktopAccess($projectRoot);
|
||||
|
||||
$design = [];
|
||||
$designRaw = is_file(dirname(__DIR__) . '/design.json') ? file_get_contents(dirname(__DIR__) . '/design.json') : false;
|
||||
if ($designRaw !== false) {
|
||||
$decoded = json_decode($designRaw, true);
|
||||
$design = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
|
||||
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
|
||||
$assetVersion = static function (string $relativePath): string {
|
||||
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
|
||||
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
|
||||
|
||||
return $mtime === false ? '0' : (string) $mtime;
|
||||
};
|
||||
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
|
||||
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
$sectionKeys = array_values(array_filter(array_map(
|
||||
static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '',
|
||||
$sections
|
||||
)));
|
||||
|
||||
if ($sectionKeys === []) {
|
||||
$sectionKeys = ['overview'];
|
||||
}
|
||||
|
||||
if (!in_array($activeView, $sectionKeys, true)) {
|
||||
$activeView = $sectionKeys[0];
|
||||
}
|
||||
|
||||
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mining-Checker</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface: rgba(255, 255, 255, 0.92);
|
||||
--line: rgba(148, 163, 184, 0.24);
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--brand-accent: #14b8a6;
|
||||
--brand-accent-3: #0f766e;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mining-module-shell {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/module-assets/index.php?module=mining-checker&path=assets/css/app.css&v=<?= htmlspecialchars($assetVersion('assets/css/app.css'), ENT_QUOTES) ?>">
|
||||
</head>
|
||||
<body data-module-debug-enabled="0">
|
||||
<div class="mining-module-shell">
|
||||
<div
|
||||
id="mining-checker-app"
|
||||
data-default-project-key="<?= htmlspecialchars($defaultProjectKey, ENT_QUOTES) ?>"
|
||||
data-api-base="/api/mining-checker/index.php?path=v1"
|
||||
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
|
||||
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="/module-assets/index.php?module=mining-checker&path=assets/js/app.js&v=<?= htmlspecialchars($assetVersion('assets/js/app.js'), ENT_QUOTES) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
0
custom/apps/mining-checker/partials/.gitkeep
Normal file
0
custom/apps/mining-checker/partials/.gitkeep
Normal file
124
custom/apps/mining-checker/sql/migrations/001_init.sql
Normal file
124
custom/apps/mining-checker/sql/migrations/001_init.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies 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_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
note TEXT NULL,
|
||||
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_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
INSERT INTO miningcheck_projects (project_key, project_name)
|
||||
VALUES ('doge-main', 'DOGE Mining Main')
|
||||
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
|
||||
|
||||
INSERT INTO miningcheck_settings (
|
||||
project_key,
|
||||
baseline_measured_at,
|
||||
baseline_coins_total,
|
||||
daily_cost_amount,
|
||||
daily_cost_currency,
|
||||
preferred_currencies
|
||||
)
|
||||
VALUES (
|
||||
'doge-main',
|
||||
'2026-03-16 01:32:00',
|
||||
27.617864,
|
||||
0.3123287671,
|
||||
'EUR',
|
||||
'["DOGE","USD","EUR"]'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
baseline_measured_at = VALUES(baseline_measured_at),
|
||||
baseline_coins_total = VALUES(baseline_coins_total),
|
||||
daily_cost_amount = VALUES(daily_cost_amount),
|
||||
daily_cost_currency = VALUES(daily_cost_currency),
|
||||
preferred_currencies = VALUES(preferred_currencies);
|
||||
|
||||
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
|
||||
VALUES
|
||||
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
|
||||
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
target_amount_fiat = VALUES(target_amount_fiat),
|
||||
currency = VALUES(currency),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order);
|
||||
|
||||
INSERT INTO miningcheck_dashboard_definitions (
|
||||
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
|
||||
)
|
||||
VALUES
|
||||
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
|
||||
('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
|
||||
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
chart_type = VALUES(chart_type),
|
||||
x_field = VALUES(x_field),
|
||||
y_field = VALUES(y_field),
|
||||
aggregation = VALUES(aggregation),
|
||||
filters_json = VALUES(filters_json),
|
||||
is_active = VALUES(is_active);
|
||||
|
||||
INSERT INTO miningcheck_measurements (
|
||||
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||
)
|
||||
VALUES
|
||||
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
price_per_coin = VALUES(price_per_coin),
|
||||
price_currency = VALUES(price_currency),
|
||||
note = VALUES(note),
|
||||
source = VALUES(source);
|
||||
@@ -0,0 +1,34 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET display_timezone = 'Europe/Berlin'
|
||||
WHERE display_timezone IS NULL OR BTRIM(display_timezone) = '';
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET baseline_measured_at = ((baseline_measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE baseline_measured_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_cost_plans
|
||||
SET starts_at = ((starts_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE starts_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_measurements
|
||||
SET measured_at = ((measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE measured_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_payouts
|
||||
SET payout_at = ((payout_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE payout_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_purchased_miners
|
||||
SET purchased_at = ((purchased_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE purchased_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_fx_fetches
|
||||
SET fetched_at = ((fetched_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE fetched_at IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,72 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans_legacy AS
|
||||
SELECT *
|
||||
FROM miningcheck_cost_plans
|
||||
WHERE 1 = 0;
|
||||
|
||||
INSERT INTO miningcheck_cost_plans_legacy
|
||||
SELECT cp.*
|
||||
FROM miningcheck_cost_plans cp
|
||||
LEFT JOIN miningcheck_cost_plans_legacy legacy
|
||||
ON legacy.id = cp.id
|
||||
WHERE legacy.id IS NULL;
|
||||
|
||||
INSERT INTO miningcheck_purchased_miners (
|
||||
project_key,
|
||||
miner_offer_id,
|
||||
purchased_at,
|
||||
label,
|
||||
runtime_months,
|
||||
mining_speed_value,
|
||||
mining_speed_unit,
|
||||
bonus_speed_value,
|
||||
bonus_speed_unit,
|
||||
total_cost_amount,
|
||||
currency,
|
||||
usd_reference_amount,
|
||||
reference_price_amount,
|
||||
reference_price_currency,
|
||||
auto_renew,
|
||||
note,
|
||||
is_active
|
||||
)
|
||||
SELECT
|
||||
cp.project_key,
|
||||
NULL AS miner_offer_id,
|
||||
cp.starts_at AS purchased_at,
|
||||
cp.label,
|
||||
cp.runtime_months,
|
||||
cp.mining_speed_value,
|
||||
cp.mining_speed_unit,
|
||||
cp.bonus_speed_value,
|
||||
cp.bonus_speed_unit,
|
||||
cp.total_cost_amount,
|
||||
cp.currency,
|
||||
CASE
|
||||
WHEN COALESCE(s.report_currency, 'EUR') = 'USD' THEN cp.base_price_amount
|
||||
ELSE NULL
|
||||
END AS usd_reference_amount,
|
||||
cp.base_price_amount AS reference_price_amount,
|
||||
COALESCE(s.report_currency, 'EUR') AS reference_price_currency,
|
||||
cp.auto_renew,
|
||||
CASE
|
||||
WHEN cp.note IS NULL OR BTRIM(cp.note) = '' THEN 'Migriert aus miningcheck_cost_plans'
|
||||
ELSE cp.note || ' | Migriert aus miningcheck_cost_plans'
|
||||
END AS note,
|
||||
cp.is_active
|
||||
FROM miningcheck_cost_plans cp
|
||||
LEFT JOIN miningcheck_settings s
|
||||
ON s.project_key = cp.project_key
|
||||
LEFT JOIN miningcheck_purchased_miners pm
|
||||
ON pm.project_key = cp.project_key
|
||||
AND pm.miner_offer_id IS NULL
|
||||
AND pm.purchased_at = cp.starts_at
|
||||
AND pm.label = cp.label
|
||||
AND pm.total_cost_amount = cp.total_cost_amount
|
||||
AND pm.currency = cp.currency
|
||||
WHERE pm.id IS NULL;
|
||||
|
||||
DELETE FROM miningcheck_cost_plans;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,15 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16),
|
||||
ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET module_theme_mode = 'inherit'
|
||||
WHERE module_theme_mode IS NULL OR BTRIM(module_theme_mode) = '';
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET module_theme_accent = 'teal'
|
||||
WHERE module_theme_accent IS NULL OR BTRIM(module_theme_accent) = '';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,182 @@
|
||||
-- Bestehende benutzerspezifische Mining-Daten werden diesem Keycloak-Sub zugeordnet:
|
||||
-- adea1766-5d1c-4c2e-98bd-5239861f745f
|
||||
-- Die Keycloak-Sub ist stabiler als preferred_username und wird fuer alle benutzerspezifischen Mining-Daten genutzt.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_cost_plans
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_measurement_rates
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_payouts
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_purchased_miners
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_cost_plans
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_measurements
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_measurement_rates mr
|
||||
SET owner_sub = m.owner_sub
|
||||
FROM miningcheck_measurements m
|
||||
WHERE mr.measurement_id = m.id
|
||||
AND (mr.owner_sub IS NULL OR BTRIM(mr.owner_sub) = '');
|
||||
|
||||
UPDATE miningcheck_measurement_rates
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_payouts
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_targets
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_dashboard_definitions
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_purchased_miners
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_cost_plans
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_measurement_rates
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_payouts
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_purchased_miners
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
DROP CONSTRAINT IF EXISTS miningcheck_settings_project_key_key;
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_measurements_unique;
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_targets_project_label;
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_dashboards_project_name;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_settings'
|
||||
AND constraint_name = 'uq_mining_settings_project_owner'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_measurements'
|
||||
AND constraint_name = 'uq_mining_measurements_unique'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ADD CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_targets'
|
||||
AND constraint_name = 'uq_mining_targets_project_label'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_targets
|
||||
ADD CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_dashboard_definitions'
|
||||
AND constraint_name = 'uq_mining_dashboards_project_name'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ADD CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_owner_start
|
||||
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_owner_measured_at
|
||||
ON miningcheck_measurements(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_owner_measurement
|
||||
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_owner_payout_at
|
||||
ON miningcheck_payouts(project_key, owner_sub, payout_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_targets_project_owner
|
||||
ON miningcheck_targets(project_key, owner_sub, sort_order, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_dashboards_project_owner
|
||||
ON miningcheck_dashboard_definitions(project_key, owner_sub, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_purchased_miners_project_owner_purchased_at
|
||||
ON miningcheck_purchased_miners(project_key, owner_sub, purchased_at);
|
||||
|
||||
COMMIT;
|
||||
237
custom/apps/mining-checker/sql/schema.mysql.sql
Normal file
237
custom/apps/mining-checker/sql/schema.mysql.sql
Normal file
@@ -0,0 +1,237 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies 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_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
fx_fetch_id BIGINT UNSIGNED NULL,
|
||||
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_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
measurement_id BIGINT UNSIGNED NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate DECIMAL(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
|
||||
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
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_wallet_withdrawals (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
withdrawal_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
withdrawal_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_withdrawals_project_owner_at (project_key, owner_sub, withdrawal_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
base_price_amount DECIMAL(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount DECIMAL(20,8) NULL,
|
||||
reference_price_amount DECIMAL(20,8) NULL,
|
||||
reference_price_currency VARCHAR(10) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
fetch_id BIGINT UNSIGNED NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
|
||||
KEY idx_miningcheck_fx_rates_currency (currency_code),
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
259
custom/apps/mining-checker/sql/schema.pgsql.sql
Normal file
259
custom/apps/mining-checker/sql/schema.pgsql.sql
Normal file
@@ -0,0 +1,259 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
baseline_measured_at TIMESTAMP NOT NULL,
|
||||
baseline_coins_total NUMERIC(20,6) NOT NULL,
|
||||
daily_cost_amount NUMERIC(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10),
|
||||
crypto_currency VARCHAR(10),
|
||||
display_timezone VARCHAR(64),
|
||||
fx_max_age_hours NUMERIC(10,2),
|
||||
module_theme_mode VARCHAR(16),
|
||||
module_theme_accent VARCHAR(16),
|
||||
preferred_currencies JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at TIMESTAMP NOT NULL,
|
||||
runtime_months INTEGER NOT NULL,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
base_price_amount NUMERIC(20,8),
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
coins_total NUMERIC(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin NUMERIC(20,8),
|
||||
price_currency VARCHAR(10),
|
||||
fx_fetch_id BIGINT,
|
||||
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_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
measurement_id BIGINT NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate NUMERIC(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement
|
||||
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount NUMERIC(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at
|
||||
ON miningcheck_payouts(project_key, owner_sub, payout_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount NUMERIC(20,8),
|
||||
total_value_currency VARCHAR(10),
|
||||
wallet_balance NUMERIC(28,10),
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSONB,
|
||||
note TEXT,
|
||||
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
|
||||
image_path VARCHAR(255),
|
||||
ocr_raw_text TEXT,
|
||||
ocr_confidence NUMERIC(6,4),
|
||||
ocr_flags JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at
|
||||
ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_withdrawals (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
withdrawal_at TIMESTAMP NOT NULL,
|
||||
coins_amount NUMERIC(20,6) NOT NULL,
|
||||
withdrawal_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_withdrawals_project_owner_at
|
||||
ON miningcheck_wallet_withdrawals(project_key, owner_sub, withdrawal_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INTEGER,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
base_price_amount NUMERIC(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat NUMERIC(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')),
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSONB,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
miner_offer_id BIGINT,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INTEGER,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
total_cost_amount NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount NUMERIC(20,8),
|
||||
reference_price_amount NUMERIC(20,8),
|
||||
reference_price_currency VARCHAR(10),
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched
|
||||
ON miningcheck_fx_fetches(base_currency, fetched_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
fetch_id BIGINT NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value NUMERIC(20,10) NOT NULL,
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch
|
||||
ON miningcheck_fx_rates(fetch_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency
|
||||
ON miningcheck_fx_rates(currency_code);
|
||||
237
custom/apps/mining-checker/sql/schema.sql
Normal file
237
custom/apps/mining-checker/sql/schema.sql
Normal file
@@ -0,0 +1,237 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies 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_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
fx_fetch_id BIGINT UNSIGNED NULL,
|
||||
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_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
measurement_id BIGINT UNSIGNED NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate DECIMAL(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
|
||||
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
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_wallet_withdrawals (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
withdrawal_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
withdrawal_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_withdrawals_project_owner_at (project_key, owner_sub, withdrawal_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
base_price_amount DECIMAL(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount DECIMAL(20,8) NULL,
|
||||
reference_price_amount DECIMAL(20,8) NULL,
|
||||
reference_price_currency VARCHAR(10) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
fetch_id BIGINT UNSIGNED NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
|
||||
KEY idx_miningcheck_fx_rates_currency (currency_code),
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
75
custom/apps/mining-checker/sql/seed.sql
Normal file
75
custom/apps/mining-checker/sql/seed.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
INSERT INTO miningcheck_projects (project_key, project_name)
|
||||
VALUES ('doge-main', 'DOGE Mining Main')
|
||||
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
|
||||
|
||||
INSERT INTO miningcheck_settings (
|
||||
project_key,
|
||||
baseline_measured_at,
|
||||
baseline_coins_total,
|
||||
daily_cost_amount,
|
||||
daily_cost_currency,
|
||||
preferred_currencies
|
||||
)
|
||||
VALUES (
|
||||
'doge-main',
|
||||
'2026-03-16 01:32:00',
|
||||
27.617864,
|
||||
0.3123287671,
|
||||
'EUR',
|
||||
'["DOGE","USD","EUR"]'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
baseline_measured_at = VALUES(baseline_measured_at),
|
||||
baseline_coins_total = VALUES(baseline_coins_total),
|
||||
daily_cost_amount = VALUES(daily_cost_amount),
|
||||
daily_cost_currency = VALUES(daily_cost_currency),
|
||||
preferred_currencies = VALUES(preferred_currencies);
|
||||
|
||||
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
|
||||
VALUES
|
||||
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
|
||||
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
target_amount_fiat = VALUES(target_amount_fiat),
|
||||
currency = VALUES(currency),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order);
|
||||
|
||||
INSERT INTO miningcheck_dashboard_definitions (
|
||||
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
|
||||
)
|
||||
VALUES
|
||||
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
|
||||
('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
|
||||
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
chart_type = VALUES(chart_type),
|
||||
x_field = VALUES(x_field),
|
||||
y_field = VALUES(y_field),
|
||||
aggregation = VALUES(aggregation),
|
||||
filters_json = VALUES(filters_json),
|
||||
is_active = VALUES(is_active);
|
||||
|
||||
INSERT INTO miningcheck_measurements (
|
||||
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||
)
|
||||
VALUES
|
||||
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
price_per_coin = VALUES(price_per_coin),
|
||||
price_currency = VALUES(price_currency),
|
||||
note = VALUES(note),
|
||||
source = VALUES(source);
|
||||
2984
custom/apps/mining-checker/src/Api/Router.php
Normal file
2984
custom/apps/mining-checker/src/Api/Router.php
Normal file
File diff suppressed because it is too large
Load Diff
1787
custom/apps/mining-checker/src/Domain/AnalyticsService.php
Normal file
1787
custom/apps/mining-checker/src/Domain/AnalyticsService.php
Normal file
File diff suppressed because it is too large
Load Diff
980
custom/apps/mining-checker/src/Domain/FxService.php
Normal file
980
custom/apps/mining-checker/src/Domain/FxService.php
Normal file
@@ -0,0 +1,980 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\FxRates\Domain\FxRatesService as SharedFxRatesService;
|
||||
use Modules\FxRates\Infrastructure\ConnectionFactory as SharedFxConnectionFactory;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository as SharedFxRatesRepository;
|
||||
use Modules\FxRates\Infrastructure\ModuleConfig as SharedFxModuleConfig;
|
||||
use Modules\FxRates\Infrastructure\SettingsStore as SharedFxSettingsStore;
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
use Modules\MiningChecker\Support\DebugTrace;
|
||||
|
||||
final class FxService
|
||||
{
|
||||
private ?MiningRepository $repository;
|
||||
private string $provider;
|
||||
private string $apiBaseUrl;
|
||||
private string $currenciesApiBaseUrl;
|
||||
private string $apiKey;
|
||||
private int $timeout;
|
||||
private int $cacheTtl;
|
||||
private bool $autoFetchOnMiss;
|
||||
private array $memoryCache = [];
|
||||
private array $snapshotCache = [];
|
||||
private ?DebugTrace $debug;
|
||||
private bool $sharedServiceResolved = false;
|
||||
private ?object $sharedService = null;
|
||||
|
||||
public function __construct(
|
||||
?MiningRepository $repository = null,
|
||||
string $apiBaseUrl = 'https://currencyapi.net',
|
||||
string $currenciesApiBaseUrl = 'https://currencyapi.net',
|
||||
int $timeout = 10,
|
||||
int $cacheTtl = 21600,
|
||||
bool $autoFetchOnMiss = false,
|
||||
string $provider = 'currencyapi',
|
||||
string $apiKey = '',
|
||||
?DebugTrace $debug = null
|
||||
)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi';
|
||||
$this->apiBaseUrl = rtrim($apiBaseUrl, '/');
|
||||
$this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/');
|
||||
$this->apiKey = trim($apiKey);
|
||||
$this->timeout = max(2, $timeout);
|
||||
$this->cacheTtl = max(60, $cacheTtl);
|
||||
$this->autoFetchOnMiss = $autoFetchOnMiss;
|
||||
$this->debug = $debug;
|
||||
}
|
||||
|
||||
public function convert(?float $amount, ?string $from, ?string $to): ?float
|
||||
{
|
||||
return $this->convertAt($amount, $from, $to, null, null, null);
|
||||
}
|
||||
|
||||
public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
|
||||
{
|
||||
if ($amount === null || $from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && $normalizedFetchId !== null) {
|
||||
$snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]);
|
||||
if (is_array($snapshot)) {
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
|
||||
if ($resolved !== null) {
|
||||
return $amount * $resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($shared !== null && method_exists($shared, 'convert')) {
|
||||
$converted = $shared->convert($amount, $from, $to, $at, $windowMinutes);
|
||||
return is_numeric($converted) ? (float) $converted : null;
|
||||
}
|
||||
|
||||
$rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId);
|
||||
return $rate === null ? null : $amount * $rate;
|
||||
}
|
||||
|
||||
public function rate(?string $from, ?string $to): ?float
|
||||
{
|
||||
return $this->rateAt($from, $to, null, null, null);
|
||||
}
|
||||
|
||||
public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
|
||||
{
|
||||
$base = strtoupper(trim((string) $from));
|
||||
$target = strtoupper(trim((string) $to));
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
|
||||
if ($base === '' || $target === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($base === $target) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && $normalizedFetchId !== null) {
|
||||
$snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]);
|
||||
if (is_array($snapshot)) {
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($shared !== null && method_exists($shared, 'findRate')) {
|
||||
$resolved = $shared->findRate($from, $to, $at, $windowMinutes);
|
||||
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
|
||||
}
|
||||
|
||||
$cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]);
|
||||
if (array_key_exists($cacheKey, $this->memoryCache)) {
|
||||
return $this->memoryCache[$cacheKey];
|
||||
}
|
||||
|
||||
$stored = $this->storedRate($base, $target);
|
||||
if ($stored !== null) {
|
||||
$this->memoryCache[$cacheKey] = $stored;
|
||||
return $stored;
|
||||
}
|
||||
|
||||
$cached = $this->readFileCache($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$this->memoryCache[$cacheKey] = $cached;
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$this->autoFetchOnMiss) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $this->fetchAndPersistRate($base, $target);
|
||||
$this->memoryCache[$cacheKey] = $rate;
|
||||
if ($rate !== null) {
|
||||
$this->writeFileCache($cacheKey, $rate);
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
|
||||
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
|
||||
{
|
||||
if ($fetchId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = $this->snapshotCacheKey('fetch', [
|
||||
$fetchId,
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
|
||||
$snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array
|
||||
{
|
||||
$cacheKey = $this->snapshotCacheKey('latest', [
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'snapshot')) {
|
||||
$snapshot = $shared->snapshot($baseCurrency, null, $symbols, null);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$cacheKey = $this->snapshotCacheKey('nearest', [
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
trim($at),
|
||||
$windowMinutes ?? 0,
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
|
||||
$snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshLatestRates')) {
|
||||
return $shared->refreshLatestRates($currencies, $base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$targets = $currencies === null
|
||||
? null
|
||||
: array_values(array_unique(array_filter(array_map(
|
||||
static fn ($code): string => strtoupper(trim((string) $code)),
|
||||
$currencies
|
||||
), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase)));
|
||||
|
||||
$payload = $this->fetchLatestPayload($normalizedBase, $targets);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$forwardRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === '' || $targetCode === $normalizedBase) {
|
||||
continue;
|
||||
}
|
||||
$forwardRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
|
||||
$updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $rateDate,
|
||||
'updated_count' => count($updated),
|
||||
'rates' => $updated,
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) {
|
||||
return $shared->ensureFreshLatestRates($maxAgeHours, $base, null);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
|
||||
|
||||
if ($this->repository === null) {
|
||||
return $this->refreshLatestRates(null, $normalizedBase);
|
||||
}
|
||||
|
||||
$latestFetch = $this->repository->getLatestFxFetch($normalizedBase);
|
||||
$latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null;
|
||||
$ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null;
|
||||
$maxAgeSeconds = (int) round($maxAgeHours * 3600);
|
||||
|
||||
if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) {
|
||||
$this->debug?->add('fx.latest.reuse', [
|
||||
'base' => $normalizedBase,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $latestFetch['rate_date'] ?? null,
|
||||
'updated_count' => 0,
|
||||
'rates' => [],
|
||||
'reused' => true,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.refresh_required', [
|
||||
'base' => $normalizedBase,
|
||||
'previous_fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
$result = $this->refreshLatestRates(null, $normalizedBase);
|
||||
$result['reused'] = false;
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function probeLatestRates(string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeLatestRates')) {
|
||||
return $shared->probeLatestRates($base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
return $this->fetchLatestProbe($normalizedBase);
|
||||
}
|
||||
|
||||
public function refreshCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) {
|
||||
return $shared->refreshCurrencyCatalog();
|
||||
}
|
||||
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
$items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
|
||||
if ($items === []) {
|
||||
return [
|
||||
'synced_count' => 0,
|
||||
'currencies' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$synced = [];
|
||||
$sortOrder = 1000;
|
||||
|
||||
foreach ($items as $code => $name) {
|
||||
$normalizedCode = strtoupper(trim((string) $code));
|
||||
$normalizedName = trim((string) $name);
|
||||
if ($normalizedCode === '' || $normalizedName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currency = [
|
||||
'code' => substr($normalizedCode, 0, 10),
|
||||
'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64),
|
||||
'symbol' => substr($normalizedCode, 0, 8),
|
||||
'is_active' => 1,
|
||||
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
|
||||
'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder),
|
||||
];
|
||||
|
||||
$synced[] = $currency;
|
||||
$sortOrder++;
|
||||
}
|
||||
|
||||
usort($synced, static function (array $left, array $right): int {
|
||||
return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']];
|
||||
});
|
||||
|
||||
return [
|
||||
'synced_count' => count($synced),
|
||||
'currencies' => $synced,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) {
|
||||
return $shared->probeCurrencyCatalog();
|
||||
}
|
||||
|
||||
return $this->fetchCurrenciesProbe();
|
||||
}
|
||||
|
||||
private function fetchAndPersistRate(string $base, string $target): ?float
|
||||
{
|
||||
$payload = $this->fetchLatestPayload($base, [$target]);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rate = $rates[$target] ?? null;
|
||||
if (!is_numeric($rate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numericRate = (float) $rate;
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$this->persistRateSet($base, [$target => $numericRate], $rateDate);
|
||||
return $numericRate;
|
||||
}
|
||||
|
||||
private function fetchLatestPayload(string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, $targets);
|
||||
if ($url === null) {
|
||||
$this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
'targets' => $targets,
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.latest.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
return $this->normalizePayload($payload, $base, $targets);
|
||||
}
|
||||
|
||||
private function fetchLatestProbe(string $base): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.'];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, null);
|
||||
if ($url === null) {
|
||||
return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.probe.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.latest.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesPayload(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.currencies.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesProbe(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function storedRate(string $base, string $target): ?float
|
||||
{
|
||||
if ($this->repository === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$direct = $this->repository->getLatestFxRate($base, $target);
|
||||
if (is_array($direct) && is_numeric($direct['rate'] ?? null)) {
|
||||
return (float) $direct['rate'];
|
||||
}
|
||||
|
||||
$inverse = $this->repository->getLatestFxRate($target, $base);
|
||||
if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) {
|
||||
return 1 / (float) $inverse['rate'];
|
||||
}
|
||||
|
||||
$measurementRate = $this->repository->getLatestMeasurementRate($base, $target);
|
||||
if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) {
|
||||
return (float) $measurementRate['rate'];
|
||||
}
|
||||
|
||||
$inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base);
|
||||
if (
|
||||
is_array($inverseMeasurementRate) &&
|
||||
is_numeric($inverseMeasurementRate['rate'] ?? null) &&
|
||||
(float) $inverseMeasurementRate['rate'] > 0
|
||||
) {
|
||||
return 1 / (float) $inverseMeasurementRate['rate'];
|
||||
}
|
||||
|
||||
foreach (['USD', 'EUR'] as $viaBase) {
|
||||
if ($base === $viaBase || $target === $viaBase) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fromVia = $this->repository->getLatestFxRate($viaBase, $base);
|
||||
$toVia = $this->repository->getLatestFxRate($viaBase, $target);
|
||||
if (
|
||||
is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) &&
|
||||
is_array($toVia) && is_numeric($toVia['rate'] ?? null) &&
|
||||
(float) $fromVia['rate'] > 0
|
||||
) {
|
||||
return (float) $toVia['rate'] / (float) $fromVia['rate'];
|
||||
}
|
||||
|
||||
$fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase);
|
||||
$toViaInverse = $this->repository->getLatestFxRate($target, $viaBase);
|
||||
if (
|
||||
is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) &&
|
||||
is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) &&
|
||||
(float) $toViaInverse['rate'] > 0
|
||||
) {
|
||||
return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistRateSet(string $base, array $rates, string $rateDate): array
|
||||
{
|
||||
$normalizedBase = strtoupper($base);
|
||||
$normalizedRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedTarget = strtoupper((string) $target);
|
||||
$normalizedRates[$normalizedTarget] = (float) $rate;
|
||||
$this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate;
|
||||
$this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate);
|
||||
}
|
||||
|
||||
if ($this->repository === null) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates);
|
||||
return is_array($saved['rates'] ?? null) ? $saved['rates'] : [];
|
||||
} catch (\Throwable) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildLatestUrl(string $base, ?array $targets = null): ?string
|
||||
{
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if ($this->apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s/api/v2/rates?base=%s&output=json&key=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
$targets = $targets ?? $this->defaultCurrencies();
|
||||
return sprintf(
|
||||
'%s/latest?base=%s&symbols=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode(implode(',', $targets))
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!is_array($payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
|
||||
}
|
||||
|
||||
$allRates = $payload['rates'];
|
||||
$filteredRates = [];
|
||||
if ($targets === null) {
|
||||
foreach ($allRates as $target => $rate) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
} else {
|
||||
foreach ($targets as $target) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rate = $allRates[$targetCode] ?? null;
|
||||
if (is_numeric($rate)) {
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => strtoupper((string) ($payload['base'] ?? $base)),
|
||||
'date' => $payload['updated'] ?? null,
|
||||
'rates' => $filteredRates,
|
||||
];
|
||||
}
|
||||
|
||||
if (!is_array($payload['rates'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function extractProviderError(array $payload, string $fallback): string
|
||||
{
|
||||
foreach (['error', 'message', 'msg'] as $field) {
|
||||
$value = $payload[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
$errors = $payload['errors'] ?? null;
|
||||
if (is_array($errors)) {
|
||||
$flat = [];
|
||||
array_walk_recursive($errors, static function ($value) use (&$flat): void {
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$flat[] = trim($value);
|
||||
}
|
||||
});
|
||||
if ($flat !== []) {
|
||||
return implode(' | ', array_values(array_unique($flat)));
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function defaultCurrencies(): array
|
||||
{
|
||||
return ['EUR', 'USD'];
|
||||
}
|
||||
|
||||
private function normalizeRateDate(mixed $value): string
|
||||
{
|
||||
if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) {
|
||||
$timestamp = (int) $value;
|
||||
if ($timestamp > 0) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) {
|
||||
return $matches[0];
|
||||
}
|
||||
}
|
||||
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
private function parseStoredUtcTimestamp(string $value): ?int
|
||||
{
|
||||
$normalized = trim($value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) {
|
||||
$date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC'));
|
||||
} else {
|
||||
$date = new \DateTimeImmutable($normalized);
|
||||
}
|
||||
return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function catalogSortOrder(string $code, int $fallback): int
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'EUR' => 10,
|
||||
'USD' => 20,
|
||||
'DOGE' => 30,
|
||||
'BTC' => 40,
|
||||
'ETH' => 50,
|
||||
'USDT' => 60,
|
||||
'USDC' => 70,
|
||||
default => $fallback,
|
||||
};
|
||||
}
|
||||
|
||||
private function isCryptoCode(string $code): bool
|
||||
{
|
||||
return in_array(strtoupper($code), [
|
||||
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
|
||||
'SOL', 'USDC', 'USDT', 'XRP',
|
||||
], true);
|
||||
}
|
||||
|
||||
private function cacheFile(string $cacheKey): string
|
||||
{
|
||||
return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json';
|
||||
}
|
||||
|
||||
private function readFileCache(string $cacheKey): ?float
|
||||
{
|
||||
$file = $this->cacheFile($cacheKey);
|
||||
if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents($file), true);
|
||||
$rate = $payload['rate'] ?? null;
|
||||
return is_numeric($rate) ? (float) $rate : null;
|
||||
}
|
||||
|
||||
private function writeFileCache(string $cacheKey, float $rate): void
|
||||
{
|
||||
@file_put_contents($this->cacheFile($cacheKey), json_encode([
|
||||
'rate' => $rate,
|
||||
'cached_at' => time(),
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
private function maskUrl(string $url): string
|
||||
{
|
||||
return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string {
|
||||
$key = $matches[2] ?? '';
|
||||
if (strlen($key) <= 8) {
|
||||
return $matches[1] . $key;
|
||||
}
|
||||
|
||||
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
|
||||
}, $url) ?: $url;
|
||||
}
|
||||
|
||||
private function sharedFxService(): ?object
|
||||
{
|
||||
if ($this->sharedServiceResolved) {
|
||||
return $this->sharedService;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleBasePath = dirname(__DIR__, 2) . '/../fx-rates';
|
||||
$bootstrap = $moduleBasePath . '/bootstrap.php';
|
||||
if (!is_file($bootstrap)) {
|
||||
$this->sharedServiceResolved = true;
|
||||
return $this->sharedService = null;
|
||||
}
|
||||
|
||||
require_once $bootstrap;
|
||||
|
||||
if (!class_exists(SharedFxModuleConfig::class) || !class_exists(SharedFxRatesService::class)) {
|
||||
$this->sharedServiceResolved = true;
|
||||
return $this->sharedService = null;
|
||||
}
|
||||
|
||||
$config = SharedFxModuleConfig::load($moduleBasePath);
|
||||
$settingsStore = new SharedFxSettingsStore($config);
|
||||
$repository = new SharedFxRatesRepository(
|
||||
SharedFxConnectionFactory::make($settingsStore),
|
||||
$config->tablePrefix()
|
||||
);
|
||||
$repository->ensureSchema();
|
||||
|
||||
$this->sharedServiceResolved = true;
|
||||
return $this->sharedService = new SharedFxRatesService($repository, $settingsStore->load());
|
||||
} catch (\Throwable) {
|
||||
$this->sharedServiceResolved = true;
|
||||
return $this->sharedService = null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float
|
||||
{
|
||||
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
|
||||
if ($base === '' || $from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($base === $from && is_numeric($rates[$to] ?? null)) {
|
||||
return (float) $rates[$to];
|
||||
}
|
||||
|
||||
if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
|
||||
return 1 / (float) $rates[$from];
|
||||
}
|
||||
|
||||
if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
|
||||
return (float) $rates[$to] / (float) $rates[$from];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function snapshotCacheKey(string $prefix, array $parts): string
|
||||
{
|
||||
return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts));
|
||||
}
|
||||
|
||||
private function normalizeSymbolsForCache(?array $symbols): string
|
||||
{
|
||||
if (!is_array($symbols) || $symbols === []) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)),
|
||||
$symbols
|
||||
))));
|
||||
sort($normalized);
|
||||
return implode(',', $normalized);
|
||||
}
|
||||
}
|
||||
665
custom/apps/mining-checker/src/Domain/OcrService.php
Normal file
665
custom/apps/mining-checker/src/Domain/OcrService.php
Normal file
@@ -0,0 +1,665 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\ModuleConfig;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
|
||||
final class OcrService
|
||||
{
|
||||
private ModuleConfig $config;
|
||||
|
||||
public function __construct(ModuleConfig $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function preview(array $file, array $input): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
|
||||
}
|
||||
|
||||
$mime = mime_content_type($file['tmp_name']) ?: '';
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
|
||||
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
|
||||
}
|
||||
|
||||
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
|
||||
$uploadDir = $this->resolveUploadDir($projectKey);
|
||||
|
||||
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
|
||||
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
|
||||
$targetFile = $uploadDir . '/' . $filename;
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
|
||||
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
|
||||
}
|
||||
|
||||
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
|
||||
$flags = [];
|
||||
|
||||
if ($rawText === '') {
|
||||
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
|
||||
$flags = array_merge($flags, $providerFlags);
|
||||
} else {
|
||||
$flags[] = 'ocr_hint_text_used';
|
||||
}
|
||||
|
||||
$parsed = $this->parseText(
|
||||
$rawText,
|
||||
(string) ($input['date_context'] ?? date('Y-m-d')),
|
||||
strtoupper(trim((string) ($input['wallet_currency_hint'] ?? '')))
|
||||
);
|
||||
$parsed['image_path'] = $targetFile;
|
||||
$parsed['raw_text'] = $rawText;
|
||||
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function resolveUploadDir(string $projectKey): string
|
||||
{
|
||||
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
|
||||
$candidates = [
|
||||
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
|
||||
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->ensureWritableDirectory($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
|
||||
'candidates' => $candidates,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureWritableDirectory(string $directory): bool
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return is_writable($directory);
|
||||
}
|
||||
|
||||
return @mkdir($directory, 0775, true) || is_dir($directory);
|
||||
}
|
||||
|
||||
private function extractRawText(string $imagePath): array
|
||||
{
|
||||
$ocrConfig = $this->config->ocr();
|
||||
$providers = $ocrConfig['providers'] ?? ['tesseract'];
|
||||
$flags = [];
|
||||
|
||||
if (!is_array($providers) || $providers === []) {
|
||||
$providers = ['tesseract'];
|
||||
}
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
$providerName = strtolower(trim((string) $provider));
|
||||
if ($providerName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($providerName === 'ocrspace') {
|
||||
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
|
||||
} elseif ($providerName === 'tesseract') {
|
||||
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
|
||||
} else {
|
||||
$flags[] = 'ocr_provider_unsupported:' . $providerName;
|
||||
continue;
|
||||
}
|
||||
|
||||
$flags = array_merge($flags, $result['flags']);
|
||||
if (($result['text'] ?? '') !== '') {
|
||||
return [
|
||||
'text' => (string) $result['text'],
|
||||
'flags' => array_values(array_unique(array_merge(
|
||||
$flags,
|
||||
['ocr_provider:' . $providerName]
|
||||
))),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
|
||||
];
|
||||
}
|
||||
|
||||
private function runOcrSpace(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
|
||||
];
|
||||
}
|
||||
|
||||
$url = trim((string) ($providerConfig['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$postFields = [
|
||||
'file' => new \CURLFile($imagePath),
|
||||
'language' => (string) ($providerConfig['language'] ?? 'eng'),
|
||||
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
|
||||
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
|
||||
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
|
||||
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
|
||||
'isOverlayRequired' => 'false',
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postFields,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'apikey: ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
|
||||
];
|
||||
}
|
||||
|
||||
$flags = [];
|
||||
$rawText = '';
|
||||
$parsedResults = $payload['ParsedResults'] ?? null;
|
||||
if (is_array($parsedResults)) {
|
||||
$texts = [];
|
||||
foreach ($parsedResults as $result) {
|
||||
if (!is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
|
||||
if ($fileExitCode !== '') {
|
||||
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
|
||||
}
|
||||
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
|
||||
if ($parsedText !== '') {
|
||||
$texts[] = $parsedText;
|
||||
}
|
||||
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
|
||||
if ($resultError !== '') {
|
||||
$flags[] = 'ocrspace_result_error';
|
||||
}
|
||||
}
|
||||
$rawText = trim(implode("\n", $texts));
|
||||
}
|
||||
|
||||
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
|
||||
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
|
||||
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
|
||||
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
|
||||
|
||||
if ($httpStatus >= 400) {
|
||||
$flags[] = 'ocrspace_http_error';
|
||||
}
|
||||
|
||||
if ($ocrExitCode !== '') {
|
||||
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
|
||||
}
|
||||
|
||||
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
|
||||
|
||||
if ($isErroredOnProcessing) {
|
||||
$flags[] = 'ocrspace_processing_error';
|
||||
}
|
||||
|
||||
if ($errorMessage !== '' || $errorDetails !== '') {
|
||||
$flags[] = 'ocrspace_error';
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => $rawText,
|
||||
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
|
||||
];
|
||||
}
|
||||
|
||||
private function runTesseract(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
|
||||
if (!$this->binaryExists($binary)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
$language = (string) ($providerConfig['language'] ?? 'eng');
|
||||
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
|
||||
if ($tmpBase === false) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_tempfile_failed:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
@unlink($tmpBase);
|
||||
$command = sprintf(
|
||||
'%s %s %s -l %s 2>/dev/null',
|
||||
escapeshellcmd($binary),
|
||||
escapeshellarg($imagePath),
|
||||
escapeshellarg($tmpBase),
|
||||
escapeshellarg($language)
|
||||
);
|
||||
shell_exec($command);
|
||||
|
||||
$txtFile = $tmpBase . '.txt';
|
||||
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
|
||||
@unlink($txtFile);
|
||||
return [
|
||||
'text' => trim($text),
|
||||
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
private function binaryExists(string $binary): bool
|
||||
{
|
||||
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
|
||||
}
|
||||
|
||||
private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
|
||||
{
|
||||
$measurement = $this->parseMeasurementText($rawText, $dateContext);
|
||||
$wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint);
|
||||
|
||||
$isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0)
|
||||
&& (
|
||||
($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null
|
||||
|| ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null
|
||||
);
|
||||
|
||||
return [
|
||||
'kind' => $isWallet ? 'wallet' : 'measurement',
|
||||
'suggested' => $measurement['suggested'],
|
||||
'suggested_wallet' => $wallet['suggested_wallet'],
|
||||
'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4),
|
||||
'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'],
|
||||
];
|
||||
}
|
||||
|
||||
private function parseMeasurementText(string $rawText, string $dateContext): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$coinsTotal = null;
|
||||
$price = null;
|
||||
$currency = null;
|
||||
$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 ($normalizedText === '') {
|
||||
$flags[] = 'ocr_raw_text_empty';
|
||||
}
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
|
||||
$decimalCandidates = [];
|
||||
foreach ($numberMatches[0] ?? [] as $candidate) {
|
||||
$normalized = (float) str_replace(',', '.', $candidate);
|
||||
if ($normalized <= 0) {
|
||||
continue;
|
||||
}
|
||||
$decimalCandidates[] = [
|
||||
'raw' => $candidate,
|
||||
'value' => $normalized,
|
||||
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
|
||||
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
|
||||
$currency = strtoupper((string) $pairMatch[1]);
|
||||
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
|
||||
$currency = strtoupper((string) $currencyMatch[1]);
|
||||
} elseif (str_contains($normalizedText, '$')) {
|
||||
$currency = 'USD';
|
||||
} else {
|
||||
$flags[] = 'currency_missing';
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
|
||||
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
foreach ($lines as $line) {
|
||||
if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_line';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_context';
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
$coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool {
|
||||
if ($item['precision'] < 4) {
|
||||
return false;
|
||||
}
|
||||
if ($item['value'] <= 0 || $item['value'] >= 1000000) {
|
||||
return false;
|
||||
}
|
||||
if ($price !== null && abs($item['value'] - $price) < 0.0000005) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
if ($coinsCandidates !== []) {
|
||||
usort($coinsCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
|
||||
});
|
||||
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
|
||||
if (count($coinsCandidates) > 1) {
|
||||
$flags[] = 'coins_ambiguous';
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'coins_missing';
|
||||
}
|
||||
}
|
||||
|
||||
$priceCandidates = array_values(array_filter(
|
||||
$decimalCandidates,
|
||||
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
|
||||
));
|
||||
|
||||
if ($price === null && $priceCandidates !== []) {
|
||||
usort($priceCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
|
||||
});
|
||||
$price = round((float) $priceCandidates[0]['value'], 8);
|
||||
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
|
||||
$flags[] = 'price_ambiguous';
|
||||
}
|
||||
}
|
||||
|
||||
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
|
||||
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
|
||||
if ($fiatValue > 0) {
|
||||
$price = round($fiatValue / $coinsTotal, 8);
|
||||
$flags[] = 'price_derived_from_balance_value';
|
||||
$currency = $currency ?? 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
$measurementIndicators = 0;
|
||||
$normalizedLower = strtolower($normalizedText);
|
||||
foreach (['mining-guthaben', 'mining guthaben', 'mining-balance', 'mining balance', 'doge /', 'bonus', 'verlauf'] as $indicator) {
|
||||
if (str_contains($normalizedLower, $indicator)) {
|
||||
$measurementIndicators++;
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$coinsTotal, $price, $currency] as $field) {
|
||||
if ($field !== null) {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
|
||||
$score = $matchedFields + min(3, $measurementIndicators);
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $measurementIndicators) * 0.08) - (count($flags) * 0.04)));
|
||||
|
||||
return [
|
||||
'suggested' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'coins_total' => $coinsTotal,
|
||||
'price_per_coin' => $price,
|
||||
'price_currency' => $currency,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => $flags,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
$assetRows = [];
|
||||
foreach ($lines as $lineIndex => $line) {
|
||||
if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) {
|
||||
continue;
|
||||
}
|
||||
$amount = round((float) str_replace(',', '.', $match[1]), 10);
|
||||
$currency = strtoupper((string) $match[2]);
|
||||
if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assetRows[] = [
|
||||
'index' => $lineIndex,
|
||||
'currency' => $currency,
|
||||
'balance' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($assetRows as $assetIndex => $assetRow) {
|
||||
$currency = (string) $assetRow['currency'];
|
||||
$balanceAmount = (float) $assetRow['balance'];
|
||||
$startIndex = (int) $assetRow['index'];
|
||||
$endIndex = isset($assetRows[$assetIndex + 1]['index'])
|
||||
? (int) $assetRows[$assetIndex + 1]['index']
|
||||
: count($lines);
|
||||
|
||||
$usdCandidates = [];
|
||||
for ($i = $startIndex; $i < $endIndex; $i++) {
|
||||
if (!isset($lines[$i])) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) {
|
||||
foreach ($usdMatches as $usdMatch) {
|
||||
$usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$priceAmount = $this->pickWalletUnitPrice(
|
||||
$balanceAmount,
|
||||
$usdCandidates,
|
||||
$totalValueCurrency === 'USD' ? $totalValueAmount : null
|
||||
);
|
||||
$balances[$currency] = [
|
||||
'balance' => $balanceAmount,
|
||||
'price_amount' => $priceAmount,
|
||||
'price_currency' => $priceAmount !== null ? 'USD' : null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) {
|
||||
$walletCurrency = $walletCurrencyHint;
|
||||
$walletBalance = is_array($balances[$walletCurrencyHint])
|
||||
? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0)
|
||||
: (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 = is_array($balances[$preferredCurrency])
|
||||
? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0)
|
||||
: (float) $balances[$preferredCurrency];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($walletBalance === null) {
|
||||
$firstCurrency = array_key_first($balances);
|
||||
if (is_string($firstCurrency)) {
|
||||
$walletCurrency = $firstCurrency;
|
||||
$walletBalance = is_array($balances[$firstCurrency])
|
||||
? (float) ($balances[$firstCurrency]['balance'] ?? 0.0)
|
||||
: (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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float|int> $candidates
|
||||
*/
|
||||
private function pickWalletUnitPrice(float $balance, array $candidates, ?float $walletTotal = null): ?float
|
||||
{
|
||||
$candidates = array_values(array_filter(array_map(
|
||||
static fn (mixed $value): float => round((float) $value, 8),
|
||||
$candidates
|
||||
), static fn (float $value): bool => $value > 0));
|
||||
|
||||
if ($balance <= 0 || $candidates === []) {
|
||||
return null;
|
||||
}
|
||||
if (count($candidates) === 1) {
|
||||
$candidate = $candidates[0];
|
||||
if ($walletTotal !== null && $walletTotal > 0 && ($balance * $candidate) > ($walletTotal * 1.5)) {
|
||||
return round($candidate / $balance, 8);
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$bestPrice = null;
|
||||
$bestError = null;
|
||||
$candidateCount = count($candidates);
|
||||
|
||||
for ($i = 0; $i < $candidateCount; $i++) {
|
||||
$priceCandidate = $candidates[$i];
|
||||
for ($j = 0; $j < $candidateCount; $j++) {
|
||||
if ($i === $j) {
|
||||
continue;
|
||||
}
|
||||
$totalCandidate = $candidates[$j];
|
||||
$estimatedTotal = $balance * $priceCandidate;
|
||||
$denominator = max(abs($totalCandidate), 0.00000001);
|
||||
$error = abs($estimatedTotal - $totalCandidate) / $denominator;
|
||||
if ($bestError === null || $error < $bestError) {
|
||||
$bestError = $error;
|
||||
$bestPrice = $priceCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) {
|
||||
return round($bestPrice, 8);
|
||||
}
|
||||
|
||||
return round($candidates[count($candidates) - 1], 8);
|
||||
}
|
||||
}
|
||||
79
custom/apps/mining-checker/src/Domain/SeedData.php
Normal file
79
custom/apps/mining-checker/src/Domain/SeedData.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
final class SeedData
|
||||
{
|
||||
public static function projectKey(): string
|
||||
{
|
||||
return 'doge-main';
|
||||
}
|
||||
|
||||
public static function projectName(): string
|
||||
{
|
||||
return 'DOGE Mining Main';
|
||||
}
|
||||
|
||||
public static function settings(): array
|
||||
{
|
||||
return [
|
||||
'baseline_measured_at' => '2026-03-16 01:32:00',
|
||||
'baseline_coins_total' => 27.617864,
|
||||
'daily_cost_amount' => 0.3123287671,
|
||||
'daily_cost_currency' => 'EUR',
|
||||
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function currencies(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20],
|
||||
['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100],
|
||||
['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110],
|
||||
['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120],
|
||||
['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130],
|
||||
['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140],
|
||||
['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150],
|
||||
];
|
||||
}
|
||||
|
||||
public static function measurements(): array
|
||||
{
|
||||
return [
|
||||
['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
];
|
||||
}
|
||||
|
||||
public static function targets(): array
|
||||
{
|
||||
return [
|
||||
['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20],
|
||||
];
|
||||
}
|
||||
|
||||
public static function dashboards(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
];
|
||||
}
|
||||
}
|
||||
62
custom/apps/mining-checker/src/Domain/SeedImporter.php
Normal file
62
custom/apps/mining-checker/src/Domain/SeedImporter.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
|
||||
final class SeedImporter
|
||||
{
|
||||
private MiningRepository $repository;
|
||||
|
||||
public function __construct(MiningRepository $repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
public function import(string $projectKey): array
|
||||
{
|
||||
$seedProjectKey = SeedData::projectKey();
|
||||
if ($projectKey !== $seedProjectKey) {
|
||||
return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.'];
|
||||
}
|
||||
|
||||
$this->repository->ensureProject($projectKey, SeedData::projectName());
|
||||
$this->repository->saveSettings($projectKey, SeedData::settings());
|
||||
|
||||
$insertedMeasurements = 0;
|
||||
foreach (SeedData::measurements() as $measurement) {
|
||||
try {
|
||||
$this->repository->createMeasurement($projectKey, array_merge([
|
||||
'image_path' => null,
|
||||
'ocr_raw_text' => null,
|
||||
'ocr_confidence' => null,
|
||||
'ocr_flags' => null,
|
||||
], $measurement));
|
||||
$insertedMeasurements++;
|
||||
} catch (\Throwable $exception) {
|
||||
// Duplicate seeds are expected on repeated imports.
|
||||
}
|
||||
}
|
||||
|
||||
$targetCount = 0;
|
||||
foreach (SeedData::targets() as $target) {
|
||||
$this->repository->saveTarget($projectKey, $target);
|
||||
$targetCount++;
|
||||
}
|
||||
|
||||
$dashboardCount = 0;
|
||||
foreach (SeedData::dashboards() as $dashboard) {
|
||||
$this->repository->saveDashboard($projectKey, $dashboard);
|
||||
$dashboardCount++;
|
||||
}
|
||||
|
||||
return [
|
||||
'project_key' => $projectKey,
|
||||
'imported_measurements' => $insertedMeasurements,
|
||||
'historical_rows_total' => count(SeedData::measurements()),
|
||||
'targets_synced' => $targetCount,
|
||||
'dashboards_synced' => $dashboardCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
use App\PdoFactory;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
use PDO;
|
||||
|
||||
final class ConnectionFactory
|
||||
{
|
||||
public static function make(ModuleConfig $config): PDO
|
||||
{
|
||||
$moduleSettings = modules()->settings('mining-checker');
|
||||
$useSeparateDb = self::usesSeparateDatabase($moduleSettings);
|
||||
|
||||
if ($useSeparateDb) {
|
||||
$dbConfig = is_array($moduleSettings['db'] ?? null) ? $moduleSettings['db'] : [];
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.', 500);
|
||||
}
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
return PdoFactory::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
$dbConfig = app()->config()->dbConfig;
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
|
||||
}
|
||||
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
return PdoFactory::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
private static function usesSeparateDatabase(array $moduleSettings): bool
|
||||
{
|
||||
$raw = $moduleSettings['use_separate_db'] ?? false;
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string) $raw));
|
||||
return in_array($normalized, ['1', 'true', 'yes', 'on', 'custom'], true);
|
||||
}
|
||||
|
||||
private static function assertSupportedDriver(array $dbConfig): void
|
||||
{
|
||||
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
|
||||
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:')) {
|
||||
throw new ApiException(
|
||||
'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle den Driver auf mysql oder pgsql.',
|
||||
500,
|
||||
['driver' => $dbConfig['driver'] ?? 'unknown']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1522
custom/apps/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
1522
custom/apps/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
final class ModuleConfig
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public static function load(string $moduleBasePath): self
|
||||
{
|
||||
$config = require $moduleBasePath . '/config/module.php';
|
||||
return new self(is_array($config) ? $config : []);
|
||||
}
|
||||
|
||||
public function defaultProjectKey(): string
|
||||
{
|
||||
return (string) ($this->config['default_project_key'] ?? 'doge-main');
|
||||
}
|
||||
|
||||
public function useProjectDatabase(): bool
|
||||
{
|
||||
return (bool) ($this->config['use_project_database'] ?? true);
|
||||
}
|
||||
|
||||
public function tablePrefix(): string
|
||||
{
|
||||
return (string) ($this->config['table_prefix'] ?? 'miningcheck_');
|
||||
}
|
||||
|
||||
public function uploadsDir(): string
|
||||
{
|
||||
return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir());
|
||||
}
|
||||
|
||||
public function uploadsPublicPrefix(): string
|
||||
{
|
||||
return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/');
|
||||
}
|
||||
|
||||
public function ocr(): array
|
||||
{
|
||||
return (array) ($this->config['ocr'] ?? []);
|
||||
}
|
||||
|
||||
public function fx(): array
|
||||
{
|
||||
return (array) ($this->config['fx'] ?? []);
|
||||
}
|
||||
|
||||
public function debug(): array
|
||||
{
|
||||
return (array) ($this->config['debug'] ?? []);
|
||||
}
|
||||
|
||||
public function debugDir(): string
|
||||
{
|
||||
$debug = $this->debug();
|
||||
return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug'));
|
||||
}
|
||||
}
|
||||
1538
custom/apps/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
1538
custom/apps/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
File diff suppressed because it is too large
Load Diff
191
custom/apps/mining-checker/src/Legacy/LegacyModuleStore.php
Normal file
191
custom/apps/mining-checker/src/Legacy/LegacyModuleStore.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Legacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class LegacyModuleStore
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $projectRoot,
|
||||
private readonly string $userScope,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function loadProject(string $projectKey): array
|
||||
{
|
||||
$state = $this->loadAll();
|
||||
$projects = is_array($state['projects'] ?? null) ? $state['projects'] : [];
|
||||
|
||||
if (!isset($projects[$projectKey]) || !is_array($projects[$projectKey])) {
|
||||
$projects[$projectKey] = $this->defaultProjectState($projectKey);
|
||||
$state['projects'] = $projects;
|
||||
$this->saveAll($state);
|
||||
}
|
||||
|
||||
return array_replace_recursive($this->defaultProjectState($projectKey), $projects[$projectKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $projectState
|
||||
*/
|
||||
public function saveProject(string $projectKey, array $projectState): void
|
||||
{
|
||||
$state = $this->loadAll();
|
||||
$projects = is_array($state['projects'] ?? null) ? $state['projects'] : [];
|
||||
$projects[$projectKey] = array_replace_recursive($this->defaultProjectState($projectKey), $projectState);
|
||||
$state['projects'] = $projects;
|
||||
$this->saveAll($state);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function loadAuth(): array
|
||||
{
|
||||
$state = $this->loadAll();
|
||||
$auth = is_array($state['module_auth'] ?? null) ? $state['module_auth'] : [];
|
||||
|
||||
return array_replace_recursive([
|
||||
'required' => true,
|
||||
'users' => [],
|
||||
'groups' => [],
|
||||
], $auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $auth
|
||||
*/
|
||||
public function saveAuth(array $auth): void
|
||||
{
|
||||
$state = $this->loadAll();
|
||||
$state['module_auth'] = [
|
||||
'required' => (bool) ($auth['required'] ?? true),
|
||||
'users' => array_values(array_filter(array_map('strval', (array) ($auth['users'] ?? [])))),
|
||||
'groups' => array_values(array_filter(array_map('strval', (array) ($auth['groups'] ?? [])))),
|
||||
];
|
||||
$this->saveAll($state);
|
||||
}
|
||||
|
||||
public function nextId(string $projectKey, string $bucket): int
|
||||
{
|
||||
$project = $this->loadProject($projectKey);
|
||||
$counters = is_array($project['_counters'] ?? null) ? $project['_counters'] : [];
|
||||
$next = (int) ($counters[$bucket] ?? 0) + 1;
|
||||
$counters[$bucket] = $next;
|
||||
$project['_counters'] = $counters;
|
||||
$this->saveProject($projectKey, $project);
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function loadAll(): array
|
||||
{
|
||||
$path = $this->path();
|
||||
|
||||
if (!is_file($path)) {
|
||||
return [
|
||||
'module_auth' => [
|
||||
'required' => true,
|
||||
'users' => [],
|
||||
'groups' => [],
|
||||
],
|
||||
'projects' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return [
|
||||
'module_auth' => [
|
||||
'required' => true,
|
||||
'users' => [],
|
||||
'groups' => [],
|
||||
],
|
||||
'projects' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
return is_array($data) ? $data : [
|
||||
'module_auth' => [
|
||||
'required' => true,
|
||||
'users' => [],
|
||||
'groups' => [],
|
||||
],
|
||||
'projects' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
private function saveAll(array $state): void
|
||||
{
|
||||
$directory = dirname($this->path());
|
||||
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('Mining-Checker Speicherverzeichnis konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Mining-Checker Speicherinhalt konnte nicht serialisiert werden.');
|
||||
}
|
||||
|
||||
if (file_put_contents($this->path(), $json . PHP_EOL, LOCK_EX) === false) {
|
||||
throw new RuntimeException('Mining-Checker Speicherinhalt konnte nicht geschrieben werden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function path(): string
|
||||
{
|
||||
$safeScope = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $this->userScope) ?: 'guest';
|
||||
|
||||
return $this->projectRoot . '/data/mining-checker/users/' . $safeScope . '.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultProjectState(string $projectKey): array
|
||||
{
|
||||
return [
|
||||
'_counters' => [],
|
||||
'project' => [
|
||||
'project_key' => $projectKey,
|
||||
'project_name' => strtoupper(str_replace('-', ' ', $projectKey)),
|
||||
],
|
||||
'settings' => [
|
||||
'baseline_measured_at' => gmdate('Y-m-d H:i:s'),
|
||||
'baseline_coins_total' => 0,
|
||||
'daily_cost_amount' => 0,
|
||||
'daily_cost_currency' => 'EUR',
|
||||
'report_currency' => 'EUR',
|
||||
'crypto_currency' => 'DOGE',
|
||||
'display_timezone' => 'Europe/Berlin',
|
||||
'fx_max_age_hours' => 3,
|
||||
'module_theme_mode' => 'inherit',
|
||||
'module_theme_accent' => 'teal',
|
||||
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
|
||||
],
|
||||
'measurements' => [],
|
||||
'targets' => [],
|
||||
'dashboards' => [],
|
||||
'wallet_snapshots' => [],
|
||||
'cost_plans' => [],
|
||||
'payouts' => [],
|
||||
'miner_offers' => [],
|
||||
'purchased_miners' => [],
|
||||
'fx_history' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
15
custom/apps/mining-checker/src/Legacy/UserScope.php
Normal file
15
custom/apps/mining-checker/src/Legacy/UserScope.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Legacy;
|
||||
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
final class UserScope
|
||||
{
|
||||
public static function current(): string
|
||||
{
|
||||
return ModuleHttp::currentUserScope();
|
||||
}
|
||||
}
|
||||
29
custom/apps/mining-checker/src/Support/ApiException.php
Normal file
29
custom/apps/mining-checker/src/Support/ApiException.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ApiException extends RuntimeException
|
||||
{
|
||||
private int $statusCode;
|
||||
private array $context;
|
||||
|
||||
public function __construct(string $message, int $statusCode = 400, array $context = [])
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->statusCode = $statusCode;
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function context(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
35
custom/apps/mining-checker/src/Support/DebugState.php
Normal file
35
custom/apps/mining-checker/src/Support/DebugState.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugState
|
||||
{
|
||||
private static array $trace = [];
|
||||
private static ?string $latestFilePath = null;
|
||||
|
||||
public static function replace(array $trace): void
|
||||
{
|
||||
self::$trace = $trace;
|
||||
}
|
||||
|
||||
public static function export(): array
|
||||
{
|
||||
return self::$trace;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$trace = [];
|
||||
}
|
||||
|
||||
public static function setLatestFilePath(?string $filePath): void
|
||||
{
|
||||
self::$latestFilePath = $filePath;
|
||||
}
|
||||
|
||||
public static function latestFilePath(): ?string
|
||||
{
|
||||
return self::$latestFilePath;
|
||||
}
|
||||
}
|
||||
60
custom/apps/mining-checker/src/Support/DebugTrace.php
Normal file
60
custom/apps/mining-checker/src/Support/DebugTrace.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugTrace
|
||||
{
|
||||
private bool $enabled;
|
||||
private array $entries = [];
|
||||
private ?string $filePath;
|
||||
|
||||
public function __construct(bool $enabled = false, ?string $filePath = null)
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
$this->filePath = $enabled ? $filePath : null;
|
||||
DebugState::replace([]);
|
||||
if ($this->enabled && $this->filePath !== null) {
|
||||
$this->persist();
|
||||
}
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function add(string $event, array $context = []): void
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->entries[] = [
|
||||
'time' => date('c'),
|
||||
'event' => $event,
|
||||
'context' => $context,
|
||||
];
|
||||
DebugState::replace($this->entries);
|
||||
$this->persist();
|
||||
}
|
||||
|
||||
public function export(): array
|
||||
{
|
||||
return $this->enabled ? $this->entries : [];
|
||||
}
|
||||
|
||||
private function persist(): void
|
||||
{
|
||||
if (!$this->enabled || $this->filePath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($this->filePath);
|
||||
if (!is_dir($directory)) {
|
||||
@mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
@file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
27
custom/apps/mining-checker/src/Support/Http.php
Normal file
27
custom/apps/mining-checker/src/Support/Http.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class Http
|
||||
{
|
||||
public static function json(array $payload, int $statusCode = 200): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function input(): array
|
||||
{
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw ?: '[]', true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
return $_POST;
|
||||
}
|
||||
}
|
||||
0
custom/apps/mining-checker/storage/uploads/.gitkeep
Normal file
0
custom/apps/mining-checker/storage/uploads/.gitkeep
Normal file
1210
custom/apps/pihole/api/index.php
Normal file
1210
custom/apps/pihole/api/index.php
Normal file
File diff suppressed because it is too large
Load Diff
121
custom/apps/pihole/assets/pihole-native.js
Normal file
121
custom/apps/pihole/assets/pihole-native.js
Normal file
@@ -0,0 +1,121 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function toPartialUrl(input, fallbackView) {
|
||||
const url = new URL(input, window.location.origin);
|
||||
if (!url.searchParams.has('view') && fallbackView) {
|
||||
url.searchParams.set('view', fallbackView);
|
||||
}
|
||||
url.searchParams.set('partial', '1');
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchHtml(url, options) {
|
||||
const response = await fetch(url.toString(), {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function enhanceHost(host, state) {
|
||||
host.querySelectorAll('a[href]').forEach((link) => {
|
||||
const href = link.getAttribute('href') || '';
|
||||
if (!href.startsWith('/apps/pihole')) {
|
||||
return;
|
||||
}
|
||||
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
state.load(href);
|
||||
});
|
||||
});
|
||||
|
||||
host.querySelectorAll('form').forEach((form) => {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const method = String(form.getAttribute('method') || 'get').toUpperCase();
|
||||
const action = form.getAttribute('action') || state.currentUrl.toString();
|
||||
const formData = new FormData(form);
|
||||
const url = toPartialUrl(action, state.defaultView);
|
||||
|
||||
try {
|
||||
if (method === 'GET') {
|
||||
const next = new URL(url.toString());
|
||||
next.search = '';
|
||||
next.searchParams.set('partial', '1');
|
||||
for (const [key, value] of formData.entries()) {
|
||||
next.searchParams.append(key, String(value));
|
||||
}
|
||||
await state.load(next.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await fetchHtml(url, {
|
||||
method,
|
||||
body: formData
|
||||
});
|
||||
state.render(html, url);
|
||||
} catch (error) {
|
||||
state.renderError(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initContent(host) {
|
||||
if (typeof window.initPiholeModule === 'function') {
|
||||
window.initPiholeModule(host);
|
||||
}
|
||||
if (typeof window.initPiholeInstancesModule === 'function') {
|
||||
window.initPiholeInstancesModule(host);
|
||||
}
|
||||
}
|
||||
|
||||
window.initPiholeApp = function initPiholeApp(host, options) {
|
||||
if (!(host instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryRoute = String(options?.entryRoute || '/apps/pihole/index.php');
|
||||
const defaultView = String(options?.defaultView || 'dashboard');
|
||||
|
||||
const state = {
|
||||
defaultView,
|
||||
currentUrl: toPartialUrl(entryRoute, defaultView),
|
||||
async load(targetUrl) {
|
||||
const url = toPartialUrl(targetUrl, defaultView);
|
||||
this.currentUrl = url;
|
||||
host.innerHTML = '<div class="window-app-loading"><p>Pi-hole wird geladen...</p></div>';
|
||||
try {
|
||||
const html = await fetchHtml(url);
|
||||
this.render(html, url);
|
||||
} catch (error) {
|
||||
this.renderError(error);
|
||||
}
|
||||
},
|
||||
render(html, url) {
|
||||
this.currentUrl = url;
|
||||
host.innerHTML = html;
|
||||
enhanceHost(host, this);
|
||||
initContent(host);
|
||||
},
|
||||
renderError(error) {
|
||||
const message = error instanceof Error ? error.message : 'Pi-hole konnte nicht geladen werden.';
|
||||
host.innerHTML = `<div class="window-app-error-state"><p>${message}</p></div>`;
|
||||
}
|
||||
};
|
||||
|
||||
state.load(entryRoute);
|
||||
};
|
||||
})();
|
||||
419
custom/apps/pihole/assets/pihole.css
Normal file
419
custom/apps/pihole/assets/pihole.css
Normal file
@@ -0,0 +1,419 @@
|
||||
.pihole-frame {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pihole-panel-shell {
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.pihole-content {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pihole-card,
|
||||
.module-box,
|
||||
.module-box-soft {
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pihole-message {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.pihole-message--error {
|
||||
color: #991b1b;
|
||||
background: rgba(254, 226, 226, 0.9);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
.pihole-message--success {
|
||||
color: #166534;
|
||||
background: rgba(220, 252, 231, 0.9);
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
.pihole-grid,
|
||||
.module-box-grid--stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pihole-stat {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pihole-stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.pihole-stat-sub {
|
||||
margin-top: 4px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pihole-section-header,
|
||||
.module-box-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-section-title,
|
||||
.module-box-title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pihole-actions,
|
||||
.pihole-card-actions,
|
||||
.pihole-modal-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pihole-primary-action,
|
||||
.cta-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #14b8a6, #0f766e);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pihole-link-button,
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pihole-inline {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pihole-inline input {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.pihole-instance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance,
|
||||
.pihole-instance-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance-value {
|
||||
font-weight: 700;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pihole-status {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: rgba(20, 184, 166, 0.15);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.pihole-status.is-disabled {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.pihole-status.is-partial {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pihole-split,
|
||||
.module-box-grid--panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pihole-list,
|
||||
.pihole-blocked {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-list-row,
|
||||
.pihole-blocked-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(248, 250, 252, 0.95);
|
||||
}
|
||||
|
||||
.pihole-blocked-row {
|
||||
background: rgba(254, 242, 242, 0.9);
|
||||
}
|
||||
|
||||
.pihole-update {
|
||||
margin-top: 10px;
|
||||
font-size: 0.95rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pihole-error {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.pihole-form,
|
||||
.pihole-setup-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-form {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.pihole-form label,
|
||||
.pihole-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pihole-field input,
|
||||
.pihole-field select,
|
||||
.pihole-form input,
|
||||
.pihole-form select,
|
||||
.pihole-form textarea,
|
||||
.pihole-instance-form input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.pihole-setup-grid,
|
||||
.pihole-instance-form-grid,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-form-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pihole-checkbox-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pihole-checkbox-field input {
|
||||
width: 18px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.pihole-page.is-busy {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.24);
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.modal.is-open,
|
||||
.modal[aria-hidden="false"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(720px, 96vw);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 28px 64px rgba(15, 23, 42, 0.18);
|
||||
padding: 20px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pihole-console-heading {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pihole-console-heading strong {
|
||||
font-size: 1.05rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
min-width: 110px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pihole-console-body {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 48vh;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.pihole-console-line {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pihole-console-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pihole-console-line span {
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pihole-console-line strong {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pihole-console-detail {
|
||||
font-size: 0.92rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.pihole-console-line.is-success {
|
||||
border-color: rgba(20, 184, 166, 0.22);
|
||||
background: rgba(20, 184, 166, 0.08);
|
||||
}
|
||||
|
||||
.pihole-console-line.is-error {
|
||||
border-color: rgba(239, 68, 68, 0.24);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.pihole-test-result {
|
||||
margin-top: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pihole-test-result.is-ok { color: #0f766e; }
|
||||
.pihole-test-result.is-auth,
|
||||
.pihole-test-result.is-unreachable,
|
||||
.pihole-test-result.is-error { color: #b91c1c; }
|
||||
.pihole-test-result.is-invalid { color: #92400e; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.pihole-frame {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pihole-actions,
|
||||
.pihole-card-actions,
|
||||
.pihole-modal-actions,
|
||||
.pihole-inline {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pihole-inline input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
488
custom/apps/pihole/assets/pihole.js
Normal file
488
custom/apps/pihole/assets/pihole.js
Normal file
@@ -0,0 +1,488 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.initPiholeModule = function initPiholeModule(rootNode) {
|
||||
const scope = rootNode instanceof Element ? rootNode : document;
|
||||
const page = scope.matches?.('[data-pihole-page]') ? scope : scope.querySelector('[data-pihole-page]');
|
||||
if (!page || page.dataset.piholeBound === '1') return;
|
||||
page.dataset.piholeBound = '1';
|
||||
|
||||
const pageType = page.dataset.piholePage || 'dashboard';
|
||||
const configuredRefreshSeconds = Number(page.dataset.refreshSeconds || 0);
|
||||
const defaultRefreshSeconds = pageType === 'dashboard' ? 1 : 5;
|
||||
const refreshSeconds = Number.isFinite(configuredRefreshSeconds) && configuredRefreshSeconds >= 0
|
||||
? configuredRefreshSeconds
|
||||
: defaultRefreshSeconds;
|
||||
|
||||
const fmt = new Intl.NumberFormat('de-DE');
|
||||
const fmtDate = (ts) => {
|
||||
if (!ts) return '–';
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString('de-DE');
|
||||
};
|
||||
|
||||
let refreshTimer = null;
|
||||
let loadInFlight = false;
|
||||
let actionInFlight = false;
|
||||
let actionConsoleApi = null;
|
||||
let actionConsoleBody = null;
|
||||
let actionConsoleClose = null;
|
||||
let actionConsoleTitle = null;
|
||||
let actionConsoleSubtitle = null;
|
||||
|
||||
const apiCall = async (action, payload = {}) => {
|
||||
const res = await fetch(`/api/pihole/index.php?action=${encodeURIComponent(action)}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
if (data && data.results && typeof data.results === 'object') {
|
||||
const failures = Object.values(data.results).filter((row) => row && row.ok === false);
|
||||
if (failures.length) {
|
||||
const first = failures[0];
|
||||
throw new Error(first.error || 'action_failed');
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const query = (selector) => page.querySelector(selector);
|
||||
const setText = (el, value) => {
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
|
||||
const ensureActionConsole = () => {
|
||||
if (actionConsoleApi && actionConsoleBody && actionConsoleClose) return;
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'modal';
|
||||
root.dataset.piholeConsoleModal = '1';
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
root.innerHTML = `
|
||||
<div class="modal-card pihole-console-modal" role="dialog" aria-modal="true" aria-labelledby="pihole-console-title">
|
||||
<div class="modal-header">
|
||||
<div class="pihole-console-heading">
|
||||
<strong id="pihole-console-title">Pi-hole Aktion</strong>
|
||||
<div class="muted" data-pihole-console-subtitle>Status und Rueckmeldungen zur laufenden Aktion.</div>
|
||||
</div>
|
||||
<div class="pihole-card-actions">
|
||||
<button class="pihole-link-button" type="button" data-pihole-console-clear>Protokoll leeren</button>
|
||||
<button class="icon-button" type="button" data-pihole-console-close aria-label="Fenster schliessen">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pihole-console-body" data-pihole-console-body></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
|
||||
actionConsoleTitle = root.querySelector('#pihole-console-title');
|
||||
actionConsoleSubtitle = root.querySelector('[data-pihole-console-subtitle]');
|
||||
actionConsoleBody = root.querySelector('[data-pihole-console-body]');
|
||||
actionConsoleClose = root.querySelector('[data-pihole-console-close]');
|
||||
const clearBtn = root.querySelector('[data-pihole-console-clear]');
|
||||
|
||||
actionConsoleApi = {
|
||||
open() {
|
||||
root.classList.add('is-open');
|
||||
root.setAttribute('aria-hidden', 'false');
|
||||
},
|
||||
close() {
|
||||
root.classList.remove('is-open');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
actionConsoleClose?.addEventListener('click', () => {
|
||||
if (!actionInFlight) actionConsoleApi.close();
|
||||
});
|
||||
|
||||
clearBtn?.addEventListener('click', () => {
|
||||
if (actionConsoleBody) actionConsoleBody.innerHTML = '';
|
||||
});
|
||||
};
|
||||
|
||||
const setActionConsoleMeta = (title, subtitle) => {
|
||||
ensureActionConsole();
|
||||
if (actionConsoleTitle) actionConsoleTitle.textContent = title || 'Pi-hole Aktion';
|
||||
if (actionConsoleSubtitle) actionConsoleSubtitle.textContent = subtitle || 'Status und Rueckmeldungen zur laufenden Aktion.';
|
||||
};
|
||||
|
||||
const appendActionLog = (message, tone = 'info', detail = '') => {
|
||||
ensureActionConsole();
|
||||
if (!actionConsoleBody) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = `pihole-console-line is-${tone}`;
|
||||
const timestamp = new Date().toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
row.innerHTML = `
|
||||
<div class="pihole-console-meta">
|
||||
<span>${timestamp}</span>
|
||||
<span>${tone === 'success' ? 'Erfolg' : tone === 'error' ? 'Fehler' : 'Info'}</span>
|
||||
</div>
|
||||
<strong>${message}</strong>
|
||||
${detail ? `<div class="pihole-console-detail">${detail}</div>` : ''}
|
||||
`;
|
||||
actionConsoleBody.appendChild(row);
|
||||
actionConsoleBody.scrollTop = actionConsoleBody.scrollHeight;
|
||||
};
|
||||
|
||||
const setActionLock = (locked) => {
|
||||
actionInFlight = locked;
|
||||
page.classList.toggle('is-busy', locked);
|
||||
ensureActionConsole();
|
||||
if (actionConsoleClose) {
|
||||
actionConsoleClose.disabled = locked;
|
||||
}
|
||||
|
||||
page.querySelectorAll('button, input, select, textarea').forEach((formControl) => {
|
||||
if (locked) {
|
||||
formControl.dataset.piholeWasDisabled = formControl.disabled ? 'true' : 'false';
|
||||
formControl.disabled = true;
|
||||
return;
|
||||
}
|
||||
formControl.disabled = formControl.dataset.piholeWasDisabled === 'true';
|
||||
delete formControl.dataset.piholeWasDisabled;
|
||||
});
|
||||
};
|
||||
|
||||
const statusLabel = (status) => {
|
||||
if (status === 'enabled') return 'Aktiv';
|
||||
if (status === 'disabled') return 'Deaktiviert';
|
||||
if (status === 'partial') return 'Teilweise';
|
||||
return 'Unbekannt';
|
||||
};
|
||||
|
||||
const actionLabel = (action) => {
|
||||
if (action === 'enable') return 'Pi-hole aktivieren';
|
||||
if (action === 'disable' || action === 'disable-custom') return 'Pi-hole temporaer deaktivieren';
|
||||
if (action === 'gravity') return 'Listen aktualisieren';
|
||||
if (action === 'update') return 'Pi-hole aktualisieren';
|
||||
return 'Pi-hole Aktion';
|
||||
};
|
||||
|
||||
const targetLabel = (instance) => {
|
||||
if (!instance || instance === 'all') return 'alle Instanzen';
|
||||
if (instance === 'primary') return 'die Primaer-Instanz';
|
||||
return `Instanz ${instance}`;
|
||||
};
|
||||
|
||||
const setStatusBadge = (el, status) => {
|
||||
if (!el) return;
|
||||
el.textContent = statusLabel(status);
|
||||
el.classList.remove('is-disabled', 'is-partial');
|
||||
if (status === 'disabled') el.classList.add('is-disabled');
|
||||
if (status === 'partial') el.classList.add('is-partial');
|
||||
};
|
||||
|
||||
const mapToList = (map, limit = 10) => {
|
||||
if (!map || typeof map !== 'object') return [];
|
||||
return Object.entries(map)
|
||||
.filter(([, value]) => typeof value === 'number' || typeof value === 'string')
|
||||
.map(([key, value]) => [key, Number(value)])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
const renderList = (container, map, emptyText) => {
|
||||
if (!container) return;
|
||||
const rows = mapToList(map, 10);
|
||||
container.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'muted';
|
||||
div.textContent = emptyText || 'Keine Daten';
|
||||
container.appendChild(div);
|
||||
return;
|
||||
}
|
||||
rows.forEach(([label, value]) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pihole-list-row';
|
||||
row.innerHTML = `<strong>${label}</strong><span>${fmt.format(value)}</span>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const renderBlocked = (container, list) => {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'muted';
|
||||
div.textContent = 'Keine Daten';
|
||||
container.appendChild(div);
|
||||
return;
|
||||
}
|
||||
list.slice(0, 20).forEach((entry) => {
|
||||
const domain = entry.domain || entry;
|
||||
const instance = entry.instance || '';
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pihole-blocked-row';
|
||||
row.innerHTML = `<span>${domain}</span><span class="muted">${instance}</span>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const renderInstances = (instances) => {
|
||||
const holder = query('[data-instance-cards]');
|
||||
const tpl = scope.querySelector('#pihole-instance-template');
|
||||
if (!holder || !tpl) return;
|
||||
holder.innerHTML = '';
|
||||
|
||||
Object.values(instances).forEach((entry) => {
|
||||
const node = tpl.content.cloneNode(true);
|
||||
const root = node.querySelector('[data-instance]');
|
||||
if (!root) return;
|
||||
root.dataset.instance = entry.meta.id;
|
||||
|
||||
const summary = entry.summary || {};
|
||||
setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id);
|
||||
setText(root.querySelector('[data-instance-url]'), entry.meta.url || '');
|
||||
setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
|
||||
setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0)));
|
||||
setText(root.querySelector('[data-instance-percent]'), `${Number(summary.ads_percentage_today || summary.ads_percentage || 0).toFixed(2)}%`);
|
||||
setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown');
|
||||
|
||||
root.querySelectorAll('[data-instance]').forEach((btn) => {
|
||||
if (btn.dataset.instance === '') btn.dataset.instance = entry.meta.id;
|
||||
});
|
||||
const customInput = root.querySelector('[data-custom-minutes]');
|
||||
if (customInput) customInput.dataset.customMinutes = entry.meta.id;
|
||||
|
||||
const updateEl = root.querySelector('[data-instance-update]');
|
||||
if (updateEl) {
|
||||
updateEl.textContent = entry.updates && entry.updates.available ? 'Updates verfuegbar' : 'Keine Updates erkannt';
|
||||
}
|
||||
|
||||
const errorEl = root.querySelector('[data-instance-errors]');
|
||||
if (errorEl) {
|
||||
if (Array.isArray(entry.errors) && entry.errors.length) {
|
||||
const lines = entry.errors.map((err) => {
|
||||
const code = err.http_code ? `HTTP ${err.http_code}` : 'HTTP ?';
|
||||
const scopeName = err.scope || 'request';
|
||||
const msg = err.error || 'error';
|
||||
const hint = err.hint ? `, Hint: ${typeof err.hint === 'string' ? err.hint : JSON.stringify(err.hint)}` : '';
|
||||
return `${scopeName}: ${msg} (${code}${hint})`;
|
||||
});
|
||||
errorEl.textContent = `API Fehler: ${lines.join(' | ')}`;
|
||||
} else {
|
||||
errorEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
holder.appendChild(node);
|
||||
});
|
||||
};
|
||||
|
||||
const renderDashboardData = (data) => {
|
||||
const summary = data.aggregate?.summary || {};
|
||||
setText(query('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
|
||||
setText(query('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0)));
|
||||
setText(query('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`);
|
||||
setText(query('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0)));
|
||||
setStatusBadge(query('[data-summary-status]'), summary.status || 'unknown');
|
||||
setText(query('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`);
|
||||
|
||||
renderInstances(data.instances || {});
|
||||
renderList(query('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten');
|
||||
renderList(query('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten');
|
||||
renderList(query('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten');
|
||||
renderList(query('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten');
|
||||
renderList(query('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten');
|
||||
renderBlocked(query('[data-recent-blocked]'), data.aggregate?.recent_blocked);
|
||||
};
|
||||
|
||||
const dashboardFingerprint = (data) => JSON.stringify({
|
||||
blocked_domains: Number(data?.aggregate?.summary?.blocked_domains || 0),
|
||||
ads_blocked_today: Number(data?.aggregate?.summary?.ads_blocked_today || 0),
|
||||
unique_domains: Number(data?.aggregate?.summary?.unique_domains || 0),
|
||||
instances: Object.values(data?.instances || {}).map((entry) => ({
|
||||
id: entry?.meta?.id || '',
|
||||
blocked_domains: Number(entry?.summary?.blocked_domains || 0),
|
||||
ads_blocked_today: Number(entry?.summary?.ads_blocked_today || 0),
|
||||
unique_domains: Number(entry?.summary?.unique_domains || 0),
|
||||
})),
|
||||
});
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
const monitorGravityProgress = async (baselineData, instanceLabel) => {
|
||||
const baselineFingerprint = dashboardFingerprint(baselineData || {});
|
||||
const maxPolls = 24;
|
||||
const pollDelayMs = 5000;
|
||||
|
||||
appendActionLog(`Pi-hole meldet keinen Live-Fortschritt. Pruefe jetzt zyklisch auf erkennbare Aenderungen fuer ${instanceLabel}.`, 'info');
|
||||
|
||||
for (let attempt = 1; attempt <= maxPolls; attempt += 1) {
|
||||
await delay(pollDelayMs);
|
||||
appendActionLog(`Pruefung ${attempt}/${maxPolls}: aktuelle Statusdaten werden geladen ...`, 'info');
|
||||
try {
|
||||
const data = await apiCall('dashboard');
|
||||
if (!data.ok) throw new Error(data.error || 'API error');
|
||||
renderDashboardData(data);
|
||||
if (dashboardFingerprint(data) !== baselineFingerprint) {
|
||||
appendActionLog(`Erkennbare Aenderung gefunden. Listen-Update fuer ${instanceLabel} scheint abgeschlossen zu sein.`, 'success');
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
appendActionLog(`Statuspruefung fehlgeschlagen: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
appendActionLog(`Innerhalb des Beobachtungsfensters wurde keine erkennbare Aenderung gefunden.`, 'error');
|
||||
return false;
|
||||
};
|
||||
|
||||
const loadDashboard = async () => {
|
||||
if (loadInFlight || actionInFlight) return;
|
||||
loadInFlight = true;
|
||||
try {
|
||||
const data = await apiCall('dashboard');
|
||||
if (!data.ok) throw new Error(data.error || 'API error');
|
||||
renderDashboardData(data);
|
||||
query('[data-pihole-load-error]')?.remove();
|
||||
} catch (err) {
|
||||
let message = query('[data-pihole-load-error]');
|
||||
if (!message) {
|
||||
message = document.createElement('div');
|
||||
message.className = 'window-app-card pihole-card';
|
||||
message.style.marginTop = '1rem';
|
||||
message.dataset.piholeLoadError = '1';
|
||||
page.appendChild(message);
|
||||
}
|
||||
message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`;
|
||||
} finally {
|
||||
loadInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
page.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const instance = btn.dataset.instance || 'all';
|
||||
const minutes = btn.dataset.minutes;
|
||||
const buttonScope = btn.closest('.pihole-instance') || page;
|
||||
const customInput = buttonScope.querySelector(`[data-custom-minutes="${instance}"]`);
|
||||
const payload = { instance };
|
||||
let baselineData = null;
|
||||
|
||||
if (action === 'disable') payload.minutes = Number(minutes || 0);
|
||||
if (action === 'disable-custom') payload.minutes = Number(customInput?.value || 0);
|
||||
|
||||
try {
|
||||
ensureActionConsole();
|
||||
if (actionConsoleBody) actionConsoleBody.innerHTML = '';
|
||||
setActionConsoleMeta(actionLabel(action), `Rueckmeldungen fuer ${targetLabel(instance)}.`);
|
||||
actionConsoleApi.open();
|
||||
setActionLock(true);
|
||||
|
||||
baselineData = action === 'gravity' ? await apiCall('dashboard').catch(() => null) : null;
|
||||
|
||||
if (action === 'enable') {
|
||||
appendActionLog('Aktivierung wird ausgefuehrt.', 'info', `Ziel: ${targetLabel(instance)}`);
|
||||
await apiCall('enable', payload);
|
||||
} else if (action === 'disable' || action === 'disable-custom') {
|
||||
if (!payload.minutes || payload.minutes <= 0) {
|
||||
appendActionLog('Fehler: Bitte Minuten angeben.', 'error');
|
||||
return;
|
||||
}
|
||||
appendActionLog('Temporaere Deaktivierung wird ausgefuehrt.', 'info', `Ziel: ${targetLabel(instance)} | Dauer: ${payload.minutes} Minuten`);
|
||||
await apiCall('disable', payload);
|
||||
} else if (action === 'gravity') {
|
||||
appendActionLog('Listen-Update wurde gestartet.', 'info', `Ziel: ${targetLabel(instance)}`);
|
||||
await apiCall('gravity', payload);
|
||||
const status = query('[data-list-update-status]');
|
||||
if (status) status.textContent = 'Listen-Update gestartet.';
|
||||
} else if (action === 'update') {
|
||||
appendActionLog('Pi-hole-Update wurde gestartet.', 'info', `Ziel: ${targetLabel(instance)}`);
|
||||
await apiCall('update', payload);
|
||||
}
|
||||
|
||||
appendActionLog('Aktion erfolgreich abgeschlossen.', 'success', 'Dashboard wird jetzt aktualisiert.');
|
||||
if (action === 'gravity') {
|
||||
await monitorGravityProgress(baselineData, targetLabel(instance));
|
||||
} else {
|
||||
await loadDashboard();
|
||||
}
|
||||
} catch (err) {
|
||||
appendActionLog(`Aktion fehlgeschlagen: ${err.message}`, 'error');
|
||||
} finally {
|
||||
setActionLock(false);
|
||||
}
|
||||
});
|
||||
|
||||
const domainForm = query('[data-domain-form]');
|
||||
domainForm?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(domainForm);
|
||||
const type = String(formData.get('type') || 'block');
|
||||
const domain = String(formData.get('domain') || '').trim();
|
||||
const status = query('[data-domain-status]');
|
||||
if (!domain) return;
|
||||
try {
|
||||
await apiCall('domain_add', { type, domain, instance: 'primary' });
|
||||
if (status) status.textContent = `Domain hinzugefuegt: ${domain}`;
|
||||
domainForm.reset();
|
||||
} catch (err) {
|
||||
if (status) status.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
const adlistForm = query('[data-adlist-form]');
|
||||
adlistForm?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(adlistForm);
|
||||
const url = String(formData.get('url') || '').trim();
|
||||
const status = query('[data-adlist-status]');
|
||||
if (!url) return;
|
||||
try {
|
||||
await apiCall('adlist_add', { url, instance: 'primary' });
|
||||
if (status) status.textContent = `Adlist hinzugefuegt: ${url}`;
|
||||
adlistForm.reset();
|
||||
} catch (err) {
|
||||
if (status) status.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer !== null) {
|
||||
window.clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
stopAutoRefresh();
|
||||
if (refreshSeconds <= 0 || document.visibilityState !== 'visible') return;
|
||||
refreshTimer = window.setInterval(loadDashboard, refreshSeconds * 1000);
|
||||
};
|
||||
|
||||
loadDashboard();
|
||||
startAutoRefresh();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadDashboard();
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
window.addEventListener('beforeunload', stopAutoRefresh);
|
||||
};
|
||||
|
||||
if (document.querySelector('[data-pihole-page]')) {
|
||||
window.initPiholeModule(document);
|
||||
}
|
||||
})();
|
||||
121
custom/apps/pihole/assets/pihole_instances.js
Normal file
121
custom/apps/pihole/assets/pihole_instances.js
Normal file
@@ -0,0 +1,121 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.initPiholeInstancesModule = function initPiholeInstancesModule(rootNode) {
|
||||
const scope = rootNode instanceof Element ? rootNode : document;
|
||||
const modal = scope.querySelector('[data-instance-modal]');
|
||||
const form = scope.querySelector('[data-instance-form]');
|
||||
|
||||
scope.querySelectorAll('[data-instance-test]').forEach((button) => {
|
||||
if (button.dataset.piholeBound === '1') return;
|
||||
button.dataset.piholeBound = '1';
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
const card = button.closest('[data-instance-id]');
|
||||
if (!card) return;
|
||||
|
||||
const instanceId = card.dataset.instanceId || '';
|
||||
const resultEl = card.querySelector('[data-instance-result]');
|
||||
if (resultEl) {
|
||||
resultEl.classList.remove('is-ok', 'is-auth', 'is-unreachable', 'is-invalid', 'is-error');
|
||||
resultEl.textContent = 'Teste Verbindung...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/pihole/index.php?action=test`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ instance: instanceId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
if (resultEl) {
|
||||
resultEl.classList.add(data.status ? `is-${data.status}` : 'is-ok');
|
||||
resultEl.textContent = data.message || 'Verbindung OK.';
|
||||
}
|
||||
} catch (err) {
|
||||
if (resultEl) {
|
||||
resultEl.classList.add('is-error');
|
||||
resultEl.textContent = `Test fehlgeschlagen: ${err.message || 'Fehler'}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!modal || !form || modal.dataset.piholeBound === '1') return;
|
||||
modal.dataset.piholeBound = '1';
|
||||
|
||||
const title = scope.querySelector('[data-instance-modal-title]');
|
||||
const closeBtn = scope.querySelector('[data-instance-close]');
|
||||
const newBtn = scope.querySelector('[data-instance-new]');
|
||||
const cancelBtn = scope.querySelector('[data-instance-cancel]');
|
||||
const currentIdInput = form.querySelector('input[name="current_id"]');
|
||||
const idInput = form.querySelector('input[name="instance_id"]');
|
||||
const nameInput = form.querySelector('input[name="name"]');
|
||||
const urlInput = form.querySelector('input[name="url"]');
|
||||
const passwordInput = form.querySelector('input[name="password"]');
|
||||
const primaryInput = form.querySelector('input[name="is_primary"]');
|
||||
|
||||
const resetForm = () => {
|
||||
if (currentIdInput) currentIdInput.value = '';
|
||||
if (idInput) idInput.value = '';
|
||||
if (nameInput) nameInput.value = '';
|
||||
if (urlInput) urlInput.value = '';
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
if (primaryInput) primaryInput.checked = false;
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
modal.classList.add('is-open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
if (idInput) window.setTimeout(() => idInput.focus(), 20);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
modal.classList.remove('is-open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
|
||||
scope.querySelectorAll('[data-instance-edit]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const card = btn.closest('[data-instance-id]');
|
||||
if (!card) return;
|
||||
if (currentIdInput) currentIdInput.value = card.dataset.instanceId || '';
|
||||
if (idInput) idInput.value = card.dataset.instanceId || '';
|
||||
if (nameInput) nameInput.value = card.dataset.name || '';
|
||||
if (urlInput) urlInput.value = card.dataset.url || '';
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
if (primaryInput) primaryInput.checked = card.dataset.primary === '1';
|
||||
if (title) title.textContent = 'Instanz bearbeiten';
|
||||
openModal();
|
||||
});
|
||||
});
|
||||
|
||||
newBtn?.addEventListener('click', () => {
|
||||
resetForm();
|
||||
if (title) title.textContent = 'Neue Instanz';
|
||||
openModal();
|
||||
});
|
||||
|
||||
closeBtn?.addEventListener('click', closeModal);
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
resetForm();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) closeModal();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (document.querySelector('[data-instance-modal]')) {
|
||||
window.initPiholeInstancesModule(document);
|
||||
}
|
||||
})();
|
||||
116
custom/apps/pihole/bootstrap.php
Normal file
116
custom/apps/pihole/bootstrap.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
use App\ModuleConfigException;
|
||||
|
||||
$moduleName = 'pihole';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'settings', function () use ($moduleName): array {
|
||||
return modules()->settings($moduleName);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'instances', function () use ($moduleName): array {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$apiPath = trim((string)($settings['api_path'] ?? '/admin/api.php'));
|
||||
if ($apiPath === '') {
|
||||
$apiPath = '/admin/api.php';
|
||||
}
|
||||
if ($apiPath[0] !== '/') {
|
||||
$apiPath = '/' . $apiPath;
|
||||
}
|
||||
|
||||
$timeout = (int)($settings['api_timeout_sec'] ?? 8);
|
||||
if ($timeout <= 0) {
|
||||
$timeout = 8;
|
||||
}
|
||||
|
||||
$verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
|
||||
|
||||
$normalizeSecret = static function (array $row): string {
|
||||
$password = trim((string)($row['password'] ?? ''));
|
||||
if ($password !== '') {
|
||||
return $password;
|
||||
}
|
||||
|
||||
return trim((string)($row['token'] ?? ''));
|
||||
};
|
||||
|
||||
$instances = [];
|
||||
|
||||
$rawJson = trim((string)($settings['instances_json'] ?? ''));
|
||||
if ($rawJson !== '') {
|
||||
$decoded = json_decode($rawJson, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$id = trim((string)($row['id'] ?? ''));
|
||||
$url = trim((string)($row['url'] ?? ''));
|
||||
if ($id === '' || $url === '') {
|
||||
continue;
|
||||
}
|
||||
$instances[$id] = [
|
||||
'id' => $id,
|
||||
'name' => trim((string)($row['name'] ?? '')) ?: $id,
|
||||
'url' => rtrim($url, '/'),
|
||||
'password' => $normalizeSecret($row),
|
||||
'api_path' => $apiPath,
|
||||
'timeout' => $timeout,
|
||||
'verify_tls' => $verifyTls,
|
||||
'is_primary' => !empty($row['is_primary']),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($instances)) {
|
||||
foreach (['primary', 'secondary'] as $key) {
|
||||
$urlKey = $key . '_url';
|
||||
$tokenKey = $key . '_token';
|
||||
$nameKey = $key . '_name';
|
||||
$url = trim((string)($settings[$urlKey] ?? ''));
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$instances[$key] = [
|
||||
'id' => $key,
|
||||
'name' => trim((string)($settings[$nameKey] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
|
||||
'url' => rtrim($url, '/'),
|
||||
'password' => trim((string)($settings[$tokenKey] ?? '')),
|
||||
'api_path' => $apiPath,
|
||||
'timeout' => $timeout,
|
||||
'verify_tls' => $verifyTls,
|
||||
'is_primary' => $key === 'primary',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$primaryId = trim((string)($settings['primary_id'] ?? ''));
|
||||
if ($primaryId !== '' && isset($instances[$primaryId])) {
|
||||
foreach ($instances as $id => &$row) {
|
||||
$row['is_primary'] = ($id === $primaryId);
|
||||
}
|
||||
unset($row);
|
||||
} else {
|
||||
$hasPrimary = false;
|
||||
foreach ($instances as $row) {
|
||||
if (!empty($row['is_primary'])) {
|
||||
$hasPrimary = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasPrimary && $instances) {
|
||||
$firstKey = array_key_first($instances);
|
||||
if ($firstKey !== null) {
|
||||
$instances[$firstKey]['is_primary'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $instances;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'lists_primary_only', function () use ($moduleName): bool {
|
||||
$settings = modules()->settings($moduleName);
|
||||
return !empty($settings['lists_primary_only']);
|
||||
});
|
||||
15
custom/apps/pihole/config/module.php
Normal file
15
custom/apps/pihole/config/module.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'api_path' => '/admin/api.php',
|
||||
'api_timeout_sec' => 8,
|
||||
'action_timeout_sec' => 120,
|
||||
'dashboard_refresh_sec' => 1,
|
||||
'lists_refresh_sec' => 5,
|
||||
'queries_refresh_sec' => 5,
|
||||
'verify_tls' => true,
|
||||
'lists_primary_only' => false,
|
||||
'instances_json' => '',
|
||||
'primary_id' => '',
|
||||
];
|
||||
16
custom/apps/pihole/design.json
Normal file
16
custom/apps/pihole/design.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Pi-hole",
|
||||
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "variant": "ghost" },
|
||||
{ "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Dashboard", "href": "/apps/pihole/index.php?view=dashboard", "view": "dashboard" },
|
||||
{ "label": "Listen", "href": "/apps/pihole/index.php?view=lists", "view": "lists" },
|
||||
{ "label": "Queries", "href": "/apps/pihole/index.php?view=queries", "view": "queries" },
|
||||
{ "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "view": "instances" },
|
||||
{ "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "view": "setup" }
|
||||
]
|
||||
}
|
||||
42
custom/apps/pihole/desktop.php
Normal file
42
custom/apps/pihole/desktop.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$manifest = [];
|
||||
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
|
||||
if ($manifestRaw !== false) {
|
||||
$decoded = json_decode($manifestRaw, true);
|
||||
$manifest = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return [
|
||||
'app_id' => 'pihole',
|
||||
'title' => (string) ($manifest['title'] ?? 'Pi-hole'),
|
||||
'icon' => 'PH',
|
||||
'entry_route' => '/apps/pihole/index.php',
|
||||
'window_mode' => 'window',
|
||||
'content_mode' => 'native-module',
|
||||
'default_width' => 1220,
|
||||
'default_height' => 860,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => false,
|
||||
'module_name' => 'network',
|
||||
'summary' => (string) ($manifest['description'] ?? 'Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.'),
|
||||
'native_module' => [
|
||||
'initializer' => 'initPiholeApp',
|
||||
'options' => [
|
||||
'entryRoute' => '/apps/pihole/index.php',
|
||||
'defaultView' => 'dashboard',
|
||||
],
|
||||
'assets' => [
|
||||
'styles' => [
|
||||
['href' => '/module-assets/index.php?module=pihole&path=assets/pihole.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.css') ? filemtime(__DIR__ . '/assets/pihole.css') : '0'))],
|
||||
],
|
||||
'scripts' => [
|
||||
['src' => '/module-assets/index.php?module=pihole&path=assets/pihole.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.js') ? filemtime(__DIR__ . '/assets/pihole.js') : '0'))],
|
||||
['src' => '/module-assets/index.php?module=pihole&path=assets/pihole_instances.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole_instances.js') ? filemtime(__DIR__ . '/assets/pihole_instances.js') : '0'))],
|
||||
['src' => '/module-assets/index.php?module=pihole&path=assets/pihole-native.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole-native.js') ? filemtime(__DIR__ . '/assets/pihole-native.js') : '0'))],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
15
custom/apps/pihole/docs/README.md
Normal file
15
custom/apps/pihole/docs/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Pi-hole
|
||||
|
||||
Importiertes Nexus-Modul fuer Pi-hole Monitoring, Listenpflege und Instanzsteuerung.
|
||||
|
||||
## Desktop-Anbindung
|
||||
|
||||
- Desktop-App unter `/apps/pihole/index.php`
|
||||
- API-Endpunkt unter `/api/pihole/index.php`
|
||||
- native Modul-Einbettung mit globaler Desktop-Sidebar
|
||||
|
||||
## Settings
|
||||
|
||||
- bestehende Nexus-Settings werden ueber `nexus_module_settings` gelesen
|
||||
- neue Overrides werden in `data/module-settings/pihole.json` gespeichert
|
||||
- Instanzen werden weiterhin im Modul-Setting `instances_json` verwaltet
|
||||
140
custom/apps/pihole/module.json
Normal file
140
custom/apps/pihole/module.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"title": "Pi-hole",
|
||||
"version": "0.1.0",
|
||||
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
|
||||
"enabled_by_default": true,
|
||||
"app_scope": "module",
|
||||
"installable": true,
|
||||
"desktop": {
|
||||
"available": true,
|
||||
"show_on_desktop": true,
|
||||
"show_in_start_menu": true
|
||||
},
|
||||
"widgets": [
|
||||
{
|
||||
"widget_id": "pihole-maintenance",
|
||||
"title": "Pi-hole Wartung",
|
||||
"icon": "PH",
|
||||
"zone": "sidebar",
|
||||
"default_enabled": false,
|
||||
"supports_public_home": false,
|
||||
"summary": "Gravity-Update und Listen-Refresh direkt aus dem Desktop.",
|
||||
"content": "Schnellaktionen fuer die Pflege der Pi-hole-Instanzen.",
|
||||
"launch_app_id": "pihole",
|
||||
"action_label": "App oeffnen",
|
||||
"widget_type": "action-panel",
|
||||
"status_api": "/api/pihole/index.php?action=widget_status",
|
||||
"actions": [
|
||||
{
|
||||
"action_id": "gravity",
|
||||
"label": "Gravity aktualisieren",
|
||||
"endpoint": "/api/pihole/index.php?action=gravity",
|
||||
"method": "POST",
|
||||
"payload": {
|
||||
"instance": "primary"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action_id": "update",
|
||||
"label": "Pi-hole updaten",
|
||||
"endpoint": "/api/pihole/index.php?action=update",
|
||||
"method": "POST",
|
||||
"payload": {
|
||||
"instance": "primary"
|
||||
},
|
||||
"variant": "secondary"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cron_jobs": [
|
||||
{
|
||||
"job_id": "gravity-primary",
|
||||
"label": "Pi-hole Gravity Update",
|
||||
"description": "Aktualisiert die Blocklisten auf der Primaer-Instanz.",
|
||||
"endpoint_path": "/api/pihole/index.php?action=gravity",
|
||||
"method": "POST",
|
||||
"payload": {
|
||||
"instance": "primary",
|
||||
"trigger_source": "cron"
|
||||
},
|
||||
"default_enabled": false,
|
||||
"default_cron": "20 4 * * *",
|
||||
"lock_minutes": 30
|
||||
},
|
||||
{
|
||||
"job_id": "update-primary",
|
||||
"label": "Pi-hole Systemupdate",
|
||||
"description": "Startet ein Update der Primaer-Instanz.",
|
||||
"endpoint_path": "/api/pihole/index.php?action=update",
|
||||
"method": "POST",
|
||||
"payload": {
|
||||
"instance": "primary",
|
||||
"trigger_source": "cron"
|
||||
},
|
||||
"default_enabled": false,
|
||||
"default_cron": "50 4 * * 0",
|
||||
"lock_minutes": 45
|
||||
}
|
||||
],
|
||||
"setup": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "api_path",
|
||||
"label": "API Pfad",
|
||||
"type": "text",
|
||||
"help": "Standard ist /admin/api.php fuer v5-Instanzen. v6 wird automatisch erkannt.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "verify_tls",
|
||||
"label": "TLS Zertifikate pruefen",
|
||||
"type": "checkbox",
|
||||
"help": "Bei internen Testsystemen nur deaktivieren, wenn self-signed Zertifikate genutzt werden.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "api_timeout_sec",
|
||||
"label": "API Timeout Standard (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Timeout fuer normale Pi-hole API-Abfragen wie Dashboard und Queries. Standard: 8 Sekunden.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "action_timeout_sec",
|
||||
"label": "API Timeout Aktionen (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Timeout fuer laengere Aktionen wie Listen- oder Pi-hole-Updates. Standard: 120 Sekunden.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "dashboard_refresh_sec",
|
||||
"label": "Refresh Dashboard (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Automatische Aktualisierung fuer das Dashboard. Standard: 1 Sekunde. 0 deaktiviert den Auto-Refresh.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "lists_refresh_sec",
|
||||
"label": "Refresh Listen (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Automatische Aktualisierung fuer die Listen-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "queries_refresh_sec",
|
||||
"label": "Refresh Queries (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Automatische Aktualisierung fuer die Queries-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "lists_primary_only",
|
||||
"label": "Listen nur auf Primaer-Instanz bearbeiten",
|
||||
"type": "checkbox",
|
||||
"help": "Erzwingt Listen- und Domain-Aenderungen nur auf der Primaer-Instanz.",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
260
custom/apps/pihole/pages/index.php
Normal file
260
custom/apps/pihole/pages/index.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
ModuleHttp::requireDesktopAccess($projectRoot);
|
||||
$isPartial = !empty($_GET['partial']);
|
||||
|
||||
$view = trim((string) ($_GET['view'] ?? 'dashboard'));
|
||||
$allowedViews = ['dashboard', 'lists', 'queries', 'instances', 'setup'];
|
||||
if (!in_array($view, $allowedViews, true)) {
|
||||
$view = 'dashboard';
|
||||
}
|
||||
|
||||
$viewMeta = [
|
||||
'dashboard' => [
|
||||
'label' => 'Dashboard',
|
||||
'title' => 'Pi-hole Dashboard',
|
||||
'description' => 'Status, Blockings, Usage und direkte Steuerung fuer alle angebundenen Instanzen.',
|
||||
'nav_description' => 'Live-Status, Sperren und aggregierte DNS-Zahlen.',
|
||||
],
|
||||
'lists' => [
|
||||
'label' => 'Listen',
|
||||
'title' => 'Listen & Domains',
|
||||
'description' => 'Gravity-Updates, Top-Domains und neue Listen-/Domain-Eintraege.',
|
||||
'nav_description' => 'Blocklisten, Whitelist und Adlist-Pflege.',
|
||||
],
|
||||
'queries' => [
|
||||
'label' => 'Queries',
|
||||
'title' => 'Zugriffe & Blockings',
|
||||
'description' => 'Aktuelle Blockings und Top-Clients ueber alle Instanzen.',
|
||||
'nav_description' => 'Abfragen, blockierte Domains und Client-Nutzung.',
|
||||
],
|
||||
'instances' => [
|
||||
'label' => 'Instanzen',
|
||||
'title' => 'Pi-hole Instanzen',
|
||||
'description' => 'Hosts, Zugangsdaten und Primaer-Instanz administrieren.',
|
||||
'nav_description' => 'Instanzen anlegen, testen, bearbeiten und loeschen.',
|
||||
],
|
||||
'setup' => [
|
||||
'label' => 'Setup',
|
||||
'title' => 'Setup',
|
||||
'description' => 'Timeouts, API-Pfad und globale Pi-hole-Moduloptionen verwalten.',
|
||||
'nav_description' => 'Standard-Timeouts, TLS und Refresh-Regeln.',
|
||||
],
|
||||
];
|
||||
|
||||
$assetVersion = static function (string $relativePath): string {
|
||||
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
|
||||
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
|
||||
|
||||
return $mtime === false ? '0' : (string) $mtime;
|
||||
};
|
||||
|
||||
$renderSetup = static function (): void {
|
||||
$moduleName = 'pihole';
|
||||
$manifestPath = dirname(__DIR__) . '/module.json';
|
||||
$manifestRaw = is_file($manifestPath) ? file_get_contents($manifestPath) : false;
|
||||
$manifest = is_string($manifestRaw) && trim($manifestRaw) !== '' ? json_decode($manifestRaw, true) : [];
|
||||
$fields = is_array($manifest['setup']['fields'] ?? null) ? $manifest['setup']['fields'] : [];
|
||||
$settings = modules()->settings($moduleName);
|
||||
$notice = null;
|
||||
$error = null;
|
||||
|
||||
$getValue = static function (array $source, string $path): mixed {
|
||||
$value = $source;
|
||||
foreach (explode('.', $path) as $segment) {
|
||||
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$segment];
|
||||
}
|
||||
|
||||
return $value;
|
||||
};
|
||||
|
||||
$setValue = static function (array &$target, string $path, mixed $value): void {
|
||||
$segments = explode('.', $path);
|
||||
$cursor = &$target;
|
||||
foreach ($segments as $index => $segment) {
|
||||
if ($index === count($segments) - 1) {
|
||||
$cursor[$segment] = $value;
|
||||
return;
|
||||
}
|
||||
if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) {
|
||||
$cursor[$segment] = [];
|
||||
}
|
||||
$cursor = &$cursor[$segment];
|
||||
}
|
||||
};
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'save_setup') {
|
||||
try {
|
||||
$nextSettings = $settings;
|
||||
foreach ($fields as $field) {
|
||||
if (!is_array($field)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($field['name'] ?? ''));
|
||||
$type = trim((string) ($field['type'] ?? 'text'));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$raw = $_POST['settings'][$name] ?? null;
|
||||
$value = match ($type) {
|
||||
'checkbox' => $raw !== null,
|
||||
'number' => ($raw === null || $raw === '') ? null : (is_numeric((string) $raw) ? 0 + $raw : null),
|
||||
default => is_string($raw) ? trim($raw) : '',
|
||||
};
|
||||
$setValue($nextSettings, $name, $value);
|
||||
}
|
||||
modules()->saveSettings($moduleName, $nextSettings);
|
||||
$settings = modules()->settings($moduleName);
|
||||
$notice = 'Setup gespeichert.';
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
?>
|
||||
<div class="pihole-content">
|
||||
<?php if ($error): ?>
|
||||
<section class="window-app-card"><div class="pihole-message pihole-message--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="window-app-card"><div class="pihole-message pihole-message--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
<section class="window-app-card">
|
||||
<div class="pihole-section-header">
|
||||
<div>
|
||||
<h3 class="pihole-section-title">Modul-Setup</h3>
|
||||
<p class="window-app-copy">Bestehende Nexus-Einstellungen werden uebernommen. Aenderungen werden lokal als Override gespeichert.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="pihole-setup-form">
|
||||
<input type="hidden" name="action" value="save_setup">
|
||||
<div class="pihole-setup-grid">
|
||||
<?php foreach ($fields as $field): ?>
|
||||
<?php
|
||||
if (!is_array($field)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($field['name'] ?? ''));
|
||||
$label = trim((string) ($field['label'] ?? $name));
|
||||
$type = trim((string) ($field['type'] ?? 'text'));
|
||||
$help = trim((string) ($field['help'] ?? ''));
|
||||
$value = $getValue($settings, $name);
|
||||
?>
|
||||
<label class="pihole-field">
|
||||
<span><?= e($label) ?></span>
|
||||
<?php if ($type === 'checkbox'): ?>
|
||||
<input type="checkbox" name="settings[<?= e($name) ?>]" value="1" <?= !empty($value) ? 'checked' : '' ?>>
|
||||
<?php elseif ($type === 'number'): ?>
|
||||
<input type="number" step="any" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
|
||||
<?php else: ?>
|
||||
<input type="<?= $type === 'password' ? 'password' : 'text' ?>" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($help !== ''): ?>
|
||||
<small class="window-app-meta"><?= e($help) ?></small>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="pihole-actions">
|
||||
<button class="window-app-nav-button pihole-primary-action" type="submit">Setup speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<?php
|
||||
};
|
||||
|
||||
$renderContent = static function () use ($view, $renderSetup, $viewMeta): void {
|
||||
$currentViewMeta = $viewMeta[$view] ?? $viewMeta['dashboard'];
|
||||
$navItems = [
|
||||
'dashboard' => '/apps/pihole/index.php?view=dashboard',
|
||||
'lists' => '/apps/pihole/index.php?view=lists',
|
||||
'queries' => '/apps/pihole/index.php?view=queries',
|
||||
'instances' => '/apps/pihole/index.php?view=instances',
|
||||
'setup' => '/apps/pihole/index.php?view=setup',
|
||||
];
|
||||
|
||||
ob_start();
|
||||
if ($view === 'setup') {
|
||||
$renderSetup();
|
||||
} else {
|
||||
require dirname(__DIR__) . '/partials/' . $view . '.php';
|
||||
}
|
||||
$innerContent = (string) ob_get_clean();
|
||||
?>
|
||||
<div class="pihole-module-shell window-app-shell">
|
||||
<div class="window-app-frame pihole-frame">
|
||||
<aside class="window-app-sidebar pihole-sidebar">
|
||||
<div class="window-app-brand pihole-brand">
|
||||
<p class="window-app-kicker">Modul</p>
|
||||
<h1>Pi-hole</h1>
|
||||
<p class="window-app-copy">Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen im Desktop-Standardlayout.</p>
|
||||
</div>
|
||||
<div class="window-app-nav-list pihole-nav-list">
|
||||
<?php foreach ($navItems as $navView => $href): ?>
|
||||
<?php $meta = $viewMeta[$navView] ?? null; ?>
|
||||
<?php if (!$meta): continue; endif; ?>
|
||||
<a class="window-app-nav-button pihole-nav-button<?= $view === $navView ? ' is-active' : '' ?>" href="<?= e($href) ?>">
|
||||
<strong><?= e((string) $meta['label']) ?></strong>
|
||||
<span class="window-app-meta"><?= e((string) $meta['nav_description']) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="window-app-main pihole-main">
|
||||
<div class="window-app-panel pihole-panel-shell">
|
||||
<section class="window-app-hero pihole-hero">
|
||||
<div>
|
||||
<p class="window-app-kicker">Pi-hole</p>
|
||||
<h2 class="window-app-title"><?= e((string) $currentViewMeta['title']) ?></h2>
|
||||
<p class="window-app-copy"><?= e((string) $currentViewMeta['description']) ?></p>
|
||||
</div>
|
||||
<div class="window-app-pill-row pihole-pill-row">
|
||||
<span class="window-app-pill">Desktop-App</span>
|
||||
<span class="window-app-pill">Netzwerk-Tools</span>
|
||||
<span class="window-app-pill">View: <?= e((string) $currentViewMeta['label']) ?></span>
|
||||
</div>
|
||||
</section>
|
||||
<?= $innerContent ?>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
};
|
||||
|
||||
ob_start();
|
||||
$renderContent();
|
||||
$pageContent = (string) ob_get_clean();
|
||||
|
||||
if ($isPartial) {
|
||||
echo $pageContent;
|
||||
return;
|
||||
}
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pi-hole</title>
|
||||
<link rel="stylesheet" href="/assets/desktop/desktop.css">
|
||||
<link rel="stylesheet" href="/module-assets/index.php?module=pihole&path=assets/pihole.css&v=<?= htmlspecialchars($assetVersion('assets/pihole.css'), ENT_QUOTES) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<?= $pageContent ?>
|
||||
|
||||
<script src="/module-assets/index.php?module=pihole&path=assets/pihole.js&v=<?= htmlspecialchars($assetVersion('assets/pihole.js'), ENT_QUOTES) ?>"></script>
|
||||
<script src="/module-assets/index.php?module=pihole&path=assets/pihole_instances.js&v=<?= htmlspecialchars($assetVersion('assets/pihole_instances.js'), ENT_QUOTES) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
134
custom/apps/pihole/partials/dashboard.php
Normal file
134
custom/apps/pihole/partials/dashboard.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
$settings = modules()->settings('pihole');
|
||||
$instances = module_fn('pihole', 'instances');
|
||||
$hasConfig = !empty($instances);
|
||||
$refreshSeconds = (int)($settings['dashboard_refresh_sec'] ?? 1);
|
||||
if ($refreshSeconds < 0) {
|
||||
$refreshSeconds = 1;
|
||||
}
|
||||
?>
|
||||
<div class="pihole-content pihole-page" data-pihole-page="dashboard" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
|
||||
|
||||
<section class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Hosts</strong>
|
||||
<a class="window-app-nav-button pihole-link-button" href="/apps/pihole/index.php?view=instances">Instanzen verwalten</a>
|
||||
</div>
|
||||
<?php if (!$instances): ?>
|
||||
<div class="muted" style="margin-top:.75rem;">Keine Pi-hole Instanzen vorhanden. Bitte zuerst hinzufuegen.</div>
|
||||
<div style="margin-top:.75rem;"><a class="window-app-nav-button pihole-primary-action" href="/apps/pihole/index.php?view=instances">+ Neue Instanz</a></div>
|
||||
<?php else: ?>
|
||||
<div class="pihole-list" style="margin-top:1rem;">
|
||||
<?php foreach ($instances as $instance): ?>
|
||||
<div class="pihole-list-row">
|
||||
<div>
|
||||
<strong><?= e((string)($instance['name'] ?? $instance['id'] ?? '')) ?></strong>
|
||||
<div class="muted"><?= e((string)($instance['url'] ?? '')) ?></div>
|
||||
</div>
|
||||
<?php if (!empty($instance['is_primary'])): ?>
|
||||
<span class="pihole-status">Primaer</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php if (!$hasConfig): ?>
|
||||
<?php return; ?>
|
||||
<?php else: ?>
|
||||
<div class="pihole-grid">
|
||||
<div class="window-app-card pihole-stat">
|
||||
<div class="muted">DNS Queries (heute)</div>
|
||||
<div class="pihole-stat-value" data-summary-dns>–</div>
|
||||
</div>
|
||||
<div class="window-app-card pihole-stat">
|
||||
<div class="muted">Ads geblockt</div>
|
||||
<div class="pihole-stat-value" data-summary-blocked>–</div>
|
||||
<div class="pihole-stat-sub" data-summary-percent>–</div>
|
||||
</div>
|
||||
<div class="window-app-card pihole-stat">
|
||||
<div class="muted">Unique Clients</div>
|
||||
<div class="pihole-stat-value" data-summary-clients>–</div>
|
||||
</div>
|
||||
<div class="window-app-card pihole-stat">
|
||||
<div class="muted">Status</div>
|
||||
<div class="pihole-stat-value" data-summary-status>–</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Blocker steuern (alle Instanzen)</strong>
|
||||
<span class="muted" data-summary-last-refresh>Letztes Update: –</span>
|
||||
</div>
|
||||
<div class="pihole-actions" data-global-actions>
|
||||
<button class="cta-button" data-action="enable" data-instance="all">Aktivieren</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="all">5 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="all">10 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="20" data-instance="all">20 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="all">30 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="60" data-instance="all">60 Min</button>
|
||||
<div class="pihole-inline">
|
||||
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="all">
|
||||
<button class="nav-link" data-action="disable-custom" data-instance="all">Custom</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Instanzen</strong>
|
||||
<span class="muted">Einzeln steuerbar & getrennte Updates</span>
|
||||
</div>
|
||||
<div class="pihole-instance-grid" data-instance-cards></div>
|
||||
</section>
|
||||
|
||||
<section class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Usage (Aggregiert)</strong>
|
||||
<span class="muted">Query-Typen und Weiterleitungen</span>
|
||||
</div>
|
||||
<div class="pihole-split" style="margin-top:1rem;">
|
||||
<div>
|
||||
<div class="muted">Query-Typen</div>
|
||||
<div class="pihole-list" data-query-types></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted">Forward Destinations</div>
|
||||
<div class="pihole-list" data-forward-destinations></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<template id="pihole-instance-template">
|
||||
<div class="window-app-card pihole-instance" data-instance="">
|
||||
<div class="pihole-instance-header">
|
||||
<div>
|
||||
<strong data-instance-name></strong>
|
||||
<div class="muted" data-instance-url></div>
|
||||
</div>
|
||||
<div class="pihole-status" data-instance-status>–</div>
|
||||
</div>
|
||||
<div class="pihole-instance-stats">
|
||||
<div><span class="muted">DNS heute</span><div class="pihole-instance-value" data-instance-dns>–</div></div>
|
||||
<div><span class="muted">Ads geblockt</span><div class="pihole-instance-value" data-instance-ads>–</div></div>
|
||||
<div><span class="muted">% Blocked</span><div class="pihole-instance-value" data-instance-percent>–</div></div>
|
||||
</div>
|
||||
<div class="pihole-actions" data-instance-actions>
|
||||
<button class="cta-button" data-action="enable" data-instance="">Aktivieren</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="">5 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="">10 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="">30 Min</button>
|
||||
<div class="pihole-inline">
|
||||
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="">
|
||||
<button class="nav-link" data-action="disable-custom" data-instance="">Custom</button>
|
||||
</div>
|
||||
<button class="nav-link" data-action="update" data-instance="">Pi-hole Update</button>
|
||||
</div>
|
||||
<div class="pihole-update" data-instance-update></div>
|
||||
<div class="pihole-error" data-instance-errors></div>
|
||||
</div>
|
||||
</template>
|
||||
408
custom/apps/pihole/partials/instances.php
Normal file
408
custom/apps/pihole/partials/instances.php
Normal file
@@ -0,0 +1,408 @@
|
||||
<?php
|
||||
$moduleName = 'pihole';
|
||||
|
||||
require_admin();
|
||||
|
||||
$settings = modules()->settings($moduleName);
|
||||
$notice = null;
|
||||
$error = null;
|
||||
$testResults = [];
|
||||
|
||||
$httpRequest = static function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array {
|
||||
$raw = '';
|
||||
$httpCode = 0;
|
||||
$requestError = '';
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => $timeout,
|
||||
CURLOPT_SSL_VERIFYPEER => $verify,
|
||||
CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$raw = (string)curl_exec($ch);
|
||||
if ($raw === '' && curl_errno($ch)) {
|
||||
$requestError = curl_error($ch);
|
||||
}
|
||||
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
} else {
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => $method,
|
||||
'timeout' => $timeout,
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $body ?? '',
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => $verify,
|
||||
'verify_peer_name' => $verify,
|
||||
],
|
||||
]);
|
||||
$raw = (string)@file_get_contents($url, false, $ctx);
|
||||
if ($raw === '') {
|
||||
$requestError = 'HTTP request failed';
|
||||
} else {
|
||||
$httpCode = 200;
|
||||
}
|
||||
}
|
||||
|
||||
if ($requestError !== '') {
|
||||
return ['ok' => false, 'http_code' => $httpCode, 'error' => $requestError, 'raw' => $raw, 'url' => $url];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return ['ok' => false, 'http_code' => $httpCode, 'error' => 'invalid_json', 'raw' => $raw, 'url' => $url];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'http_code' => $httpCode, 'data' => $decoded, 'url' => $url];
|
||||
};
|
||||
|
||||
$runConnectionTest = static function (array $instance, array $settings) use ($httpRequest): array {
|
||||
$verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
|
||||
$timeout = (int)($settings['api_timeout_sec'] ?? 8);
|
||||
if ($timeout <= 0) {
|
||||
$timeout = 8;
|
||||
}
|
||||
|
||||
$url = rtrim((string)($instance['url'] ?? ''), '/');
|
||||
$password = trim((string)($instance['password'] ?? ''));
|
||||
$v6AuthUrl = $url . '/api/auth';
|
||||
$v6Payload = json_encode(['password' => $password], JSON_UNESCAPED_UNICODE);
|
||||
$v6Auth = $httpRequest('POST', $v6AuthUrl, ['Accept: application/json', 'Content-Type: application/json'], $v6Payload, $verifyTls, $timeout);
|
||||
|
||||
if ($v6Auth['ok']) {
|
||||
$session = (array)(($v6Auth['data']['session'] ?? []) ?: []);
|
||||
$sid = trim((string)($session['sid'] ?? ''));
|
||||
if ($sid !== '') {
|
||||
$probe = $httpRequest('GET', $url . '/api/stats/summary', ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verifyTls, $timeout);
|
||||
if ($probe['ok']) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'status' => 'ok',
|
||||
'message' => 'Verbindung OK. API v6 antwortet.',
|
||||
'version' => 6,
|
||||
'details' => ['auth' => $v6Auth, 'probe' => $probe],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 'error',
|
||||
'message' => 'v6 Auth OK, aber Stats-Endpoint antwortet nicht sauber.',
|
||||
'version' => 6,
|
||||
'details' => ['auth' => $v6Auth, 'probe' => $probe],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$legacyUrl = $url . '/admin/api.php?summaryRaw';
|
||||
if ($password !== '') {
|
||||
$legacyUrl .= '&auth=' . rawurlencode($password);
|
||||
}
|
||||
$v5Probe = $httpRequest('GET', $legacyUrl, ['Accept: application/json'], null, $verifyTls, $timeout);
|
||||
if ($v5Probe['ok']) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'status' => 'ok',
|
||||
'message' => 'Verbindung OK. API v5 antwortet.',
|
||||
'version' => 5,
|
||||
'details' => ['auth' => $v6Auth, 'probe' => $v5Probe],
|
||||
];
|
||||
}
|
||||
|
||||
$status = 'error';
|
||||
$message = 'Unbekannter Fehler.';
|
||||
$httpCode = (int)($v6Auth['http_code'] ?? $v5Probe['http_code'] ?? 0);
|
||||
$requestError = (string)($v6Auth['error'] ?? $v5Probe['error'] ?? 'error');
|
||||
if ($httpCode === 0) {
|
||||
$status = 'unreachable';
|
||||
$message = 'Host nicht erreichbar oder kein HTTP-Response.';
|
||||
} elseif ($httpCode === 401 || $httpCode === 403) {
|
||||
$status = 'auth';
|
||||
$message = 'Passwort oder App-Passwort falsch oder nicht berechtigt.';
|
||||
} elseif ($requestError === 'invalid_json') {
|
||||
$status = 'invalid';
|
||||
$message = 'API antwortet nicht mit JSON. URL pruefen.';
|
||||
} else {
|
||||
$message = 'API Fehler: ' . $requestError . ' (HTTP ' . $httpCode . ')';
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'details' => ['auth' => $v6Auth, 'probe' => $v5Probe],
|
||||
];
|
||||
};
|
||||
|
||||
$loadInstances = function (array $settings): array {
|
||||
$normalizeSecret = static function (array $row): string {
|
||||
$password = trim((string)($row['password'] ?? ''));
|
||||
if ($password !== '') {
|
||||
return $password;
|
||||
}
|
||||
|
||||
return trim((string)($row['token'] ?? ''));
|
||||
};
|
||||
|
||||
$instances = [];
|
||||
$rawJson = trim((string)($settings['instances_json'] ?? ''));
|
||||
if ($rawJson !== '') {
|
||||
$decoded = json_decode($rawJson, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$id = trim((string)($row['id'] ?? ''));
|
||||
$url = trim((string)($row['url'] ?? ''));
|
||||
if ($id === '' || $url === '') {
|
||||
continue;
|
||||
}
|
||||
$instances[$id] = [
|
||||
'id' => $id,
|
||||
'name' => trim((string)($row['name'] ?? '')) ?: $id,
|
||||
'url' => $url,
|
||||
'password' => $normalizeSecret($row),
|
||||
'is_primary' => !empty($row['is_primary']),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$instances) {
|
||||
foreach (['primary', 'secondary'] as $key) {
|
||||
$url = trim((string)($settings[$key . '_url'] ?? ''));
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$instances[$key] = [
|
||||
'id' => $key,
|
||||
'name' => trim((string)($settings[$key . '_name'] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
|
||||
'url' => $url,
|
||||
'password' => trim((string)($settings[$key . '_token'] ?? '')),
|
||||
'is_primary' => $key === 'primary',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $instances;
|
||||
};
|
||||
|
||||
$instances = $loadInstances($settings);
|
||||
|
||||
$sanitizeId = function (string $id): string {
|
||||
$id = preg_replace('/[^a-zA-Z0-9_-]/', '', $id);
|
||||
return trim((string)$id);
|
||||
};
|
||||
|
||||
$saveInstances = function (array $settings, array $instances): void {
|
||||
$payload = $settings;
|
||||
$payload['instances_json'] = json_encode(array_values($instances), JSON_UNESCAPED_UNICODE);
|
||||
modules()->saveSettings('pihole', $payload);
|
||||
};
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$deleteId = trim((string)($_POST['delete_id'] ?? ''));
|
||||
$testId = trim((string)($_POST['test_id'] ?? ''));
|
||||
$currentId = trim((string)($_POST['current_id'] ?? ''));
|
||||
$instanceId = trim((string)($_POST['instance_id'] ?? ''));
|
||||
$name = trim((string)($_POST['name'] ?? ''));
|
||||
$url = trim((string)($_POST['url'] ?? ''));
|
||||
$password = trim((string)($_POST['password'] ?? ''));
|
||||
$isPrimary = isset($_POST['is_primary']);
|
||||
|
||||
if ($testId !== '') {
|
||||
if (isset($instances[$testId])) {
|
||||
module_debug_push('pihole', [
|
||||
'label' => 'connection.test.start',
|
||||
'instance_id' => $testId,
|
||||
'instance_name' => (string)($instances[$testId]['name'] ?? $testId),
|
||||
'url' => (string)($instances[$testId]['url'] ?? ''),
|
||||
]);
|
||||
|
||||
$result = $runConnectionTest($instances[$testId], $settings);
|
||||
$testResults[$testId] = $result;
|
||||
|
||||
module_debug_push('pihole', [
|
||||
'label' => 'connection.test.result',
|
||||
'instance_id' => $testId,
|
||||
'instance_name' => (string)($instances[$testId]['name'] ?? $testId),
|
||||
'result' => $result,
|
||||
]);
|
||||
|
||||
$notice = $result['message'] ?? null;
|
||||
} else {
|
||||
$error = 'Test-Instanz nicht gefunden.';
|
||||
}
|
||||
} elseif ($deleteId !== '') {
|
||||
if (isset($instances[$deleteId])) {
|
||||
unset($instances[$deleteId]);
|
||||
$notice = 'Instanz geloescht.';
|
||||
}
|
||||
} else {
|
||||
$instanceId = $sanitizeId($instanceId);
|
||||
if ($instanceId === '' || $url === '') {
|
||||
$error = 'Bitte ID und URL angeben.';
|
||||
} elseif ($currentId === '' && isset($instances[$instanceId])) {
|
||||
$error = 'Die Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.';
|
||||
} elseif ($currentId !== '' && $currentId !== $instanceId && isset($instances[$instanceId])) {
|
||||
$error = 'Die neue Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.';
|
||||
} else {
|
||||
$existingPassword = '';
|
||||
if ($currentId !== '' && isset($instances[$currentId])) {
|
||||
$existingPassword = (string)($instances[$currentId]['password'] ?? '');
|
||||
}
|
||||
$passwordToStore = $password !== '' ? $password : $existingPassword;
|
||||
if ($currentId !== '' && $currentId !== $instanceId) {
|
||||
unset($instances[$currentId]);
|
||||
}
|
||||
$instances[$instanceId] = [
|
||||
'id' => $instanceId,
|
||||
'name' => $name !== '' ? $name : $instanceId,
|
||||
'url' => $url,
|
||||
'password' => $passwordToStore,
|
||||
'is_primary' => $isPrimary,
|
||||
];
|
||||
|
||||
if ($isPrimary) {
|
||||
foreach ($instances as $id => &$row) {
|
||||
$row['is_primary'] = ($id === $instanceId);
|
||||
}
|
||||
unset($row);
|
||||
$settings['primary_id'] = $instanceId;
|
||||
}
|
||||
|
||||
$notice = $currentId !== '' ? 'Instanz aktualisiert.' : 'Instanz gespeichert.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$error) {
|
||||
$saveInstances($settings, $instances);
|
||||
$settings = modules()->settings($moduleName);
|
||||
$instances = $loadInstances($settings);
|
||||
}
|
||||
}
|
||||
|
||||
$primaryId = trim((string)($settings['primary_id'] ?? ''));
|
||||
if ($primaryId === '') {
|
||||
foreach ($instances as $id => $row) {
|
||||
if (!empty($row['is_primary'])) {
|
||||
$primaryId = $id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<div class="pihole-content">
|
||||
<section class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<div>
|
||||
<h3 class="pihole-section-title">Instanzen</h3>
|
||||
<p class="window-app-copy">Pi-hole Instanzen hinzufuegen, bearbeiten und loeschen.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button class="window-app-nav-button pihole-primary-action" type="button" data-instance-new>+ Neue Instanz</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="pihole-message pihole-message--error" style="margin-top:16px;">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="pihole-message pihole-message--success" style="margin-top:16px;">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="pihole-instance-grid" style="margin-top:16px;">
|
||||
<?php if (!$instances): ?>
|
||||
<div class="window-app-card pihole-card">Keine Instanzen vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($instances as $instance): ?>
|
||||
<div class="window-app-card pihole-instance-card"
|
||||
data-instance-id="<?= e((string)$instance['id']) ?>"
|
||||
data-name="<?= e((string)($instance['name'] ?? '')) ?>"
|
||||
data-url="<?= e((string)($instance['url'] ?? '')) ?>"
|
||||
data-primary="<?= !empty($instance['is_primary']) ? '1' : '0' ?>">
|
||||
<div class="pihole-instance-header">
|
||||
<div>
|
||||
<strong><?= e((string)($instance['name'] ?? '')) ?></strong>
|
||||
<div class="muted">ID: <?= e((string)($instance['id'] ?? '')) ?></div>
|
||||
<div class="muted">URL: <?= e((string)($instance['url'] ?? '')) ?></div>
|
||||
</div>
|
||||
<?php if (!empty($instance['is_primary']) || $instance['id'] === $primaryId): ?>
|
||||
<span class="pihole-status">Primaer</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="pihole-card-actions">
|
||||
<button class="window-app-nav-button pihole-link-button" type="button" data-instance-edit>Bearbeiten</button>
|
||||
<form method="post">
|
||||
<input type="hidden" name="test_id" value="<?= e((string)($instance['id'] ?? '')) ?>">
|
||||
<button class="window-app-nav-button pihole-link-button" type="submit" data-instance-test>Test Verbindung</button>
|
||||
</form>
|
||||
<form method="post" onsubmit="return confirm('Instanz wirklich loeschen?')">
|
||||
<input type="hidden" name="delete_id" value="<?= e((string)($instance['id'] ?? '')) ?>">
|
||||
<button class="window-app-nav-button pihole-link-button" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="pihole-test-result<?= !empty($testResults[$instance['id']]['status']) ? ' is-' . e((string)$testResults[$instance['id']]['status']) : '' ?>" data-instance-result><?= e((string)($testResults[$instance['id']]['message'] ?? '')) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal" data-instance-modal aria-hidden="true">
|
||||
<div class="modal-card pihole-modal-card" role="dialog" aria-modal="true" aria-labelledby="pihole-instance-modal-title">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<strong id="pihole-instance-modal-title" data-instance-modal-title>Neue Instanz</strong>
|
||||
<div class="muted">Pi-hole Host anlegen oder bestehende Instanz bearbeiten.</div>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-instance-close aria-label="Modal schliessen">×</button>
|
||||
</div>
|
||||
<form method="post" class="pihole-instance-form" data-instance-form>
|
||||
<input type="hidden" name="current_id" value="">
|
||||
<div class="form-grid pihole-instance-form-grid">
|
||||
<label class="form-field pihole-form-field-wide">
|
||||
<span class="muted">ID</span>
|
||||
<input type="text" name="instance_id" placeholder="z.B. pihole-main" required>
|
||||
<small class="muted">Die ID muss eindeutig sein und wird nur intern verwendet.</small>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">Name</span>
|
||||
<input type="text" name="name" placeholder="z.B. Pi-hole Main" required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">URL</span>
|
||||
<input type="text" name="url" placeholder="http://pi-hole.local" required>
|
||||
</label>
|
||||
<label class="form-field pihole-form-field-wide">
|
||||
<span class="muted">Passwort / App-Passwort</span>
|
||||
<input type="password" name="password" placeholder="Pi-hole Passwort oder App-Passwort" autocomplete="new-password">
|
||||
<small class="muted">Beim Bearbeiten leer lassen, um das gespeicherte Passwort unveraendert zu lassen.</small>
|
||||
</label>
|
||||
<label class="pihole-checkbox-field">
|
||||
<input type="checkbox" name="is_primary" value="1">
|
||||
<span>Als Primaer verwenden</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pihole-modal-actions">
|
||||
<button class="window-app-nav-button pihole-primary-action" type="submit" data-instance-submit>Speichern</button>
|
||||
<button class="window-app-nav-button pihole-link-button" type="button" data-instance-cancel>Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
88
custom/apps/pihole/partials/lists.php
Normal file
88
custom/apps/pihole/partials/lists.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
$settings = modules()->settings('pihole');
|
||||
$instances = module_fn('pihole', 'instances');
|
||||
$hasConfig = !empty($instances);
|
||||
$refreshSeconds = (int)($settings['lists_refresh_sec'] ?? 5);
|
||||
if ($refreshSeconds < 0) {
|
||||
$refreshSeconds = 5;
|
||||
}
|
||||
?>
|
||||
<div class="pihole-content pihole-page" data-pihole-page="lists" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
|
||||
<?php if (!$hasConfig): ?>
|
||||
<div class="window-app-card pihole-card">
|
||||
<strong>Keine Instanzen konfiguriert</strong>
|
||||
<div class="muted" style="margin-top:.35rem;">Bitte zuerst eine Pi-hole Instanz hinzufuegen.</div>
|
||||
<div style="margin-top:.75rem;"><a class="window-app-nav-button pihole-link-button" href="/apps/pihole/index.php?view=instances">Instanzen verwalten</a></div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<section class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Listen-Updates</strong>
|
||||
<span class="muted">Gravity / Blocklisten fuer alle oder einzelne Instanzen neu laden</span>
|
||||
</div>
|
||||
<div class="pihole-actions" data-list-actions>
|
||||
<button class="cta-button" data-action="gravity" data-instance="all">Alle Instanzen aktualisieren</button>
|
||||
<?php foreach ($instances as $instance): ?>
|
||||
<button class="nav-link" data-action="gravity" data-instance="<?= e((string)($instance['id'] ?? '')) ?>">
|
||||
<?= e((string)($instance['name'] ?? $instance['id'] ?? 'Instanz')) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="pihole-update" data-list-update-status></div>
|
||||
</section>
|
||||
|
||||
<div class="pihole-split">
|
||||
<div class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Top geblockte Domains (Aggregiert)</strong>
|
||||
<span class="muted">Letzte Statistiken</span>
|
||||
</div>
|
||||
<div class="pihole-list" data-top-ads></div>
|
||||
</div>
|
||||
<div class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Top erlaubte Domains (Aggregiert)</strong>
|
||||
<span class="muted">Letzte Statistiken</span>
|
||||
</div>
|
||||
<div class="pihole-list" data-top-queries></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Domainlisten erweitern</strong>
|
||||
<span class="muted">Eintraege werden auf der Primaer-Instanz gesetzt</span>
|
||||
</div>
|
||||
<form class="pihole-form" data-domain-form>
|
||||
<label>
|
||||
<span class="muted">Typ</span>
|
||||
<select name="type">
|
||||
<option value="block">Blacklist (Blocken)</option>
|
||||
<option value="allow">Whitelist (Erlauben)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="muted">Domain</span>
|
||||
<input type="text" name="domain" placeholder="example.com" required>
|
||||
</label>
|
||||
<button class="cta-button" type="submit">Hinzufuegen</button>
|
||||
</form>
|
||||
<div class="pihole-update" data-domain-status></div>
|
||||
</section>
|
||||
|
||||
<section class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Adlist-URL hinzufuegen</strong>
|
||||
<span class="muted">Optional: unterstuetzt nur wenn die API den Endpunkt anbietet.</span>
|
||||
</div>
|
||||
<form class="pihole-form" data-adlist-form>
|
||||
<label>
|
||||
<span class="muted">Adlist URL</span>
|
||||
<input type="text" name="url" placeholder="https://example.com/list.txt" required>
|
||||
</label>
|
||||
<button class="nav-link" type="submit">Adlist hinzufuegen</button>
|
||||
</form>
|
||||
<div class="pihole-update" data-adlist-status></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
35
custom/apps/pihole/partials/queries.php
Normal file
35
custom/apps/pihole/partials/queries.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
$settings = modules()->settings('pihole');
|
||||
$instances = module_fn('pihole', 'instances');
|
||||
$hasConfig = !empty($instances);
|
||||
$refreshSeconds = (int)($settings['queries_refresh_sec'] ?? 5);
|
||||
if ($refreshSeconds < 0) {
|
||||
$refreshSeconds = 5;
|
||||
}
|
||||
?>
|
||||
<div class="pihole-content pihole-page" data-pihole-page="queries" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
|
||||
<?php if (!$hasConfig): ?>
|
||||
<div class="window-app-card pihole-card">
|
||||
<strong>Keine Instanzen konfiguriert</strong>
|
||||
<div class="muted" style="margin-top:.35rem;">Bitte zuerst eine Pi-hole Instanz hinzufuegen.</div>
|
||||
<div style="margin-top:.75rem;"><a class="window-app-nav-button pihole-link-button" href="/apps/pihole/index.php?view=instances">Instanzen verwalten</a></div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="pihole-split">
|
||||
<div class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Aktuelle Blockings</strong>
|
||||
<span class="muted">Letzte geblockte Domains</span>
|
||||
</div>
|
||||
<div class="pihole-blocked" data-recent-blocked></div>
|
||||
</div>
|
||||
<div class="window-app-card pihole-card">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Top Clients (Aggregiert)</strong>
|
||||
<span class="muted">Anfragen nach Client</span>
|
||||
</div>
|
||||
<div class="pihole-list" data-top-clients></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
287
custom/apps/salesforce-translation-import/assets/css/app.css
Normal file
287
custom/apps/salesforce-translation-import/assets/css/app.css
Normal file
@@ -0,0 +1,287 @@
|
||||
.salesforce-translation-import,
|
||||
.salesforce-translation-import-page {
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(96, 165, 250, 0.10), transparent 24%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
color: #0f172a;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.salesforce-translation-import-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.salesforce-translation-import {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import *,
|
||||
.salesforce-translation-import *::before,
|
||||
.salesforce-translation-import *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-height: min-content;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__hero,
|
||||
.salesforce-translation-import__card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
.salesforce-translation-import__hero {
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: #4338ca;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__title {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 4vw, 2.8rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__lede {
|
||||
max-width: 70rem;
|
||||
margin: 14px 0 0;
|
||||
color: #475569;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||
}
|
||||
|
||||
.salesforce-translation-import__main,
|
||||
.salesforce-translation-import__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__card {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 1.55rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__card p {
|
||||
margin: 0 0 12px;
|
||||
color: #475569;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__card ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__card li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__sample {
|
||||
overflow-x: auto;
|
||||
margin-top: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__field + .salesforce-translation-import__field {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__label {
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__input,
|
||||
.salesforce-translation-import__select {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__button {
|
||||
min-height: 48px;
|
||||
padding: 11px 18px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.22);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__button:hover {
|
||||
border-color: rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
|
||||
.salesforce-translation-import__button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__button--primary {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__status.is-muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__status-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
|
||||
}
|
||||
|
||||
.salesforce-translation-import__status-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__file-name {
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__language-code {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.65);
|
||||
border-radius: 999px;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__match-ok {
|
||||
color: #166534;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__match-missing {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__stats {
|
||||
color: #334155;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__preview {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.salesforce-translation-import table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.salesforce-translation-import th,
|
||||
.salesforce-translation-import td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.salesforce-translation-import thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.salesforce-translation-import tbody tr:nth-child(even) {
|
||||
background: rgba(248, 250, 252, 0.75);
|
||||
}
|
||||
|
||||
.salesforce-translation-import__preview-empty {
|
||||
padding: 20px 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.salesforce-translation-import__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.salesforce-translation-import {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.salesforce-translation-import__hero,
|
||||
.salesforce-translation-import__card {
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
}
|
||||
916
custom/apps/salesforce-translation-import/assets/js/app.js
Normal file
916
custom/apps/salesforce-translation-import/assets/js/app.js
Normal file
@@ -0,0 +1,916 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const template = `
|
||||
<div class="salesforce-translation-import window-app-shell">
|
||||
<div class="salesforce-translation-import__shell">
|
||||
<section class="salesforce-translation-import__hero">
|
||||
<p class="salesforce-translation-import__eyebrow">Integration</p>
|
||||
<h1 class="salesforce-translation-import__title">Salesforce Translation Import</h1>
|
||||
<p class="salesforce-translation-import__lede">Clientseitiges Werkzeug fuer Salesforce-XLIFF-Dateien und Zieluebersetzungen aus Excel oder CSV. Der Export erzeugt pro Sprache eine separate XLIFF-Datei im ZIP.</p>
|
||||
</section>
|
||||
|
||||
<div class="salesforce-translation-import__grid">
|
||||
<div class="salesforce-translation-import__main">
|
||||
<section class="salesforce-translation-import__card">
|
||||
<h2>Import</h2>
|
||||
|
||||
<div class="salesforce-translation-import__field">
|
||||
<label class="salesforce-translation-import__label" for="salesforce-translation-import-xml-files">Salesforce XLIFF export files</label>
|
||||
<input class="salesforce-translation-import__input" type="file" id="salesforce-translation-import-xml-files" multiple accept=".zip,.xml,.xlf,.xliff">
|
||||
<div id="salesforce-translation-import-source-status" class="salesforce-translation-import__status salesforce-translation-import__status is-muted">No Salesforce XLIFF export files selected.</div>
|
||||
</div>
|
||||
|
||||
<div class="salesforce-translation-import__field">
|
||||
<label class="salesforce-translation-import__label" for="salesforce-translation-import-target-file">Target translation (Excel or CSV)</label>
|
||||
<input class="salesforce-translation-import__input" type="file" id="salesforce-translation-import-target-file" accept=".xlsx,.xls,.csv">
|
||||
<div id="salesforce-translation-import-target-status" class="salesforce-translation-import__status salesforce-translation-import__status is-muted">No target translation file selected.</div>
|
||||
<div id="salesforce-translation-import-generic-source-selection" class="salesforce-translation-import__field" hidden>
|
||||
<label class="salesforce-translation-import__label" for="salesforce-translation-import-generic-source-select">Apply target translation to</label>
|
||||
<select class="salesforce-translation-import__select" id="salesforce-translation-import-generic-source-select">
|
||||
<option value="">Select a target XLIFF file</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="salesforce-translation-import__field">
|
||||
<label class="salesforce-translation-import__label" for="salesforce-translation-import-object-select">Preview object filter</label>
|
||||
<select class="salesforce-translation-import__select" id="salesforce-translation-import-object-select">
|
||||
<option value="">All objects</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="salesforce-translation-import__actions">
|
||||
<button class="salesforce-translation-import__button salesforce-translation-import__button--primary" type="button" id="salesforce-translation-import-analyze-btn">Create preview</button>
|
||||
<button class="salesforce-translation-import__button" type="button" id="salesforce-translation-import-export-btn">Create ZIP</button>
|
||||
<button class="salesforce-translation-import__button" type="button" id="salesforce-translation-import-extract-btn">Extract CSV</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="salesforce-translation-import__card">
|
||||
<h2>Preview</h2>
|
||||
<div id="salesforce-translation-import-stats" class="salesforce-translation-import__stats"></div>
|
||||
<div class="salesforce-translation-import__preview">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Language</th>
|
||||
<th>Object</th>
|
||||
<th>Field</th>
|
||||
<th>Current target</th>
|
||||
<th>Result target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="salesforce-translation-import-preview-body">
|
||||
<tr>
|
||||
<td colspan="6" class="salesforce-translation-import__preview-empty">Noch keine Vorschau erstellt.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="salesforce-translation-import__side">
|
||||
<section class="salesforce-translation-import__card">
|
||||
<h2>Vorgehen</h2>
|
||||
<ol>
|
||||
<li>Salesforce-Zielsprachen als XLIFF exportieren. ZIP-Dateien koennen direkt hochgeladen werden.</li>
|
||||
<li>Eine Excel- oder CSV-Datei mit den Spalten <code>object</code>, <code>field</code> und <code>target</code> vorbereiten.</li>
|
||||
<li>Optional kann die Spalte <code>language</code> mehrere Zielsprachen in einer Datei abbilden.</li>
|
||||
<li>Vorschau erzeugen, Matches pruefen und danach das Ergebnis als ZIP herunterladen.</li>
|
||||
<li>Die exportierten XLIFF-Dateien einzeln wieder in Salesforce importieren.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="salesforce-translation-import__card">
|
||||
<h2>Beispielstruktur</h2>
|
||||
<p>Header werden gross-/kleinschreibungsunabhaengig ausgewertet. Die Aliasnamen <code>Objekt</code> und <code>Feld</code> bleiben ebenfalls gueltig.</p>
|
||||
<div class="salesforce-translation-import__sample">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>object</th>
|
||||
<th>field</th>
|
||||
<th>target</th>
|
||||
<th>language</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Account</td>
|
||||
<td>Name</td>
|
||||
<td>Kontoname</td>
|
||||
<td>de</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Account</td>
|
||||
<td>Name</td>
|
||||
<td>Nom du compte</td>
|
||||
<td>fr</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function createEmptyTargetStats() {
|
||||
return {
|
||||
totalRows: 0,
|
||||
withoutTarget: 0,
|
||||
sameFieldAndTarget: 0,
|
||||
incomplete: 0,
|
||||
duplicates: 0,
|
||||
usableUniqueRows: 0
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHeader(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeLanguage(value) {
|
||||
return String(value || '').trim().replaceAll('_', '-').toLowerCase();
|
||||
}
|
||||
|
||||
function parseXml(xmlText) {
|
||||
const xml = new DOMParser().parseFromString(xmlText, 'text/xml');
|
||||
const parserError = xml.getElementsByTagName('parsererror')[0];
|
||||
|
||||
if (parserError) {
|
||||
throw new Error(parserError.textContent || 'Invalid XML file');
|
||||
}
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
function getTargetLanguage(xml) {
|
||||
const fileNode = xml.getElementsByTagName('file')[0];
|
||||
const rawLanguage = fileNode ? fileNode.getAttribute('target-language') || '' : '';
|
||||
|
||||
return {
|
||||
raw: rawLanguage.trim(),
|
||||
normalized: normalizeLanguage(rawLanguage)
|
||||
};
|
||||
}
|
||||
|
||||
function findHeaderIndex(headers, aliases) {
|
||||
return headers.findIndex((header) => aliases.includes(header));
|
||||
}
|
||||
|
||||
function getUnitDetails(unit) {
|
||||
const id = unit.getAttribute('id');
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = id.split('.');
|
||||
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
objectName: parts[1].trim(),
|
||||
fieldName: parts.slice(2).join('.').trim()
|
||||
};
|
||||
}
|
||||
|
||||
function removeUnit(unit) {
|
||||
const whitespaceBefore = unit.previousSibling;
|
||||
|
||||
if (whitespaceBefore && whitespaceBefore.nodeType === 3 && /^\s*$/.test(whitespaceBefore.nodeValue)) {
|
||||
whitespaceBefore.remove();
|
||||
}
|
||||
|
||||
unit.remove();
|
||||
}
|
||||
|
||||
function csvEscape(value) {
|
||||
const text = String(value ?? '');
|
||||
return '"' + text.replaceAll('"', '""') + '"';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function buildApp(host, options) {
|
||||
host.innerHTML = template;
|
||||
|
||||
const state = {
|
||||
translationsByLanguage: new Map(),
|
||||
sourceFiles: [],
|
||||
sourceLoadPromise: Promise.resolve(),
|
||||
previewChanges: [],
|
||||
targetFileName: '',
|
||||
targetRowCount: 0,
|
||||
targetHasLanguageColumn: false,
|
||||
targetStats: createEmptyTargetStats(),
|
||||
selectedGenericSourceId: '',
|
||||
sourceSequence: 0
|
||||
};
|
||||
|
||||
const xmlInput = host.querySelector('#salesforce-translation-import-xml-files');
|
||||
const targetInput = host.querySelector('#salesforce-translation-import-target-file');
|
||||
const genericSourceSelect = host.querySelector('#salesforce-translation-import-generic-source-select');
|
||||
const analyzeBtn = host.querySelector('#salesforce-translation-import-analyze-btn');
|
||||
const exportBtn = host.querySelector('#salesforce-translation-import-export-btn');
|
||||
const extractBtn = host.querySelector('#salesforce-translation-import-extract-btn');
|
||||
const sourceStatus = host.querySelector('#salesforce-translation-import-source-status');
|
||||
const targetStatus = host.querySelector('#salesforce-translation-import-target-status');
|
||||
const statsNode = host.querySelector('#salesforce-translation-import-stats');
|
||||
const previewBody = host.querySelector('#salesforce-translation-import-preview-body');
|
||||
const objectSelect = host.querySelector('#salesforce-translation-import-object-select');
|
||||
const genericSourceSelection = host.querySelector('#salesforce-translation-import-generic-source-selection');
|
||||
|
||||
function appendMetric(container, value, label, className) {
|
||||
const metric = document.createElement('div');
|
||||
metric.className = className || 'salesforce-translation-import__muted';
|
||||
metric.textContent = value + ' ' + label;
|
||||
container.appendChild(metric);
|
||||
}
|
||||
|
||||
function resetObjectSelect() {
|
||||
objectSelect.innerHTML = '<option value="">All objects</option>';
|
||||
}
|
||||
|
||||
function populateObjectSelect(objects) {
|
||||
resetObjectSelect();
|
||||
|
||||
[...objects].sort().forEach((objectName) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = objectName;
|
||||
option.textContent = objectName;
|
||||
objectSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function getTranslationsForSource(source) {
|
||||
if (source.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (source.language && state.translationsByLanguage.has(source.language)) {
|
||||
return {
|
||||
translations: state.translationsByLanguage.get(source.language),
|
||||
label: source.language,
|
||||
languageKey: source.language
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!state.targetHasLanguageColumn &&
|
||||
(state.sourceFiles.filter((entry) => !entry.error).length === 1 || source.id === state.selectedGenericSourceId) &&
|
||||
state.translationsByLanguage.has('')
|
||||
) {
|
||||
return {
|
||||
translations: state.translationsByLanguage.get(''),
|
||||
label: 'single-source mode',
|
||||
languageKey: ''
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendStatusRow(container, source) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'salesforce-translation-import__status-row';
|
||||
|
||||
const fileName = document.createElement('span');
|
||||
fileName.className = 'salesforce-translation-import__file-name';
|
||||
fileName.textContent = source.displayName || source.name;
|
||||
row.appendChild(fileName);
|
||||
|
||||
if (source.error) {
|
||||
const error = document.createElement('span');
|
||||
error.className = 'salesforce-translation-import__match-missing';
|
||||
error.textContent = 'Invalid XLIFF: ' + source.error;
|
||||
row.appendChild(error);
|
||||
container.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
const language = document.createElement('span');
|
||||
language.className = 'salesforce-translation-import__language-code';
|
||||
language.textContent = source.languageLabel;
|
||||
row.appendChild(language);
|
||||
|
||||
const unitCount = document.createElement('span');
|
||||
unitCount.className = 'salesforce-translation-import__muted';
|
||||
unitCount.textContent = source.unitCount + ' translation units';
|
||||
row.appendChild(unitCount);
|
||||
|
||||
const match = getTranslationsForSource(source);
|
||||
const matchText = document.createElement('span');
|
||||
|
||||
if (match) {
|
||||
matchText.className = 'salesforce-translation-import__match-ok';
|
||||
matchText.textContent = 'Matched: ' + match.label;
|
||||
} else if (state.targetFileName && !state.targetHasLanguageColumn && state.sourceFiles.filter((entry) => !entry.error).length > 1) {
|
||||
matchText.className = 'salesforce-translation-import__muted';
|
||||
matchText.textContent = 'Not selected for this target translation';
|
||||
} else if (state.targetFileName) {
|
||||
matchText.className = 'salesforce-translation-import__match-missing';
|
||||
matchText.textContent = 'No matching target translations';
|
||||
} else {
|
||||
matchText.className = 'salesforce-translation-import__muted';
|
||||
matchText.textContent = 'Waiting for target translation';
|
||||
}
|
||||
|
||||
row.appendChild(matchText);
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function renderGenericSourceSelection() {
|
||||
const validSources = state.sourceFiles.filter((entry) => !entry.error);
|
||||
const selectionRequired = Boolean(state.targetFileName) && !state.targetHasLanguageColumn && validSources.length > 1;
|
||||
|
||||
genericSourceSelection.hidden = !selectionRequired;
|
||||
|
||||
if (!selectionRequired) {
|
||||
state.selectedGenericSourceId = validSources.length === 1 ? validSources[0].id : '';
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSelection = state.selectedGenericSourceId;
|
||||
genericSourceSelect.innerHTML = '<option value="">Select a target XLIFF file</option>';
|
||||
|
||||
validSources.forEach((source) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = source.id;
|
||||
option.textContent = (source.displayName || source.name) + ' [' + source.languageLabel + ']';
|
||||
genericSourceSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (validSources.some((source) => source.id === previousSelection)) {
|
||||
genericSourceSelect.value = previousSelection;
|
||||
} else {
|
||||
state.selectedGenericSourceId = '';
|
||||
genericSourceSelect.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileStatus() {
|
||||
renderGenericSourceSelection();
|
||||
|
||||
sourceStatus.innerHTML = '';
|
||||
|
||||
if (state.sourceFiles.length === 0) {
|
||||
sourceStatus.textContent = 'No Salesforce XLIFF export files selected.';
|
||||
sourceStatus.className = 'salesforce-translation-import__status salesforce-translation-import__status is-muted';
|
||||
} else {
|
||||
sourceStatus.className = 'salesforce-translation-import__status';
|
||||
state.sourceFiles.forEach((source) => appendStatusRow(sourceStatus, source));
|
||||
}
|
||||
|
||||
targetStatus.innerHTML = '';
|
||||
|
||||
if (!state.targetFileName) {
|
||||
targetStatus.textContent = 'No target translation file selected.';
|
||||
targetStatus.className = 'salesforce-translation-import__status salesforce-translation-import__status is-muted';
|
||||
return;
|
||||
}
|
||||
|
||||
targetStatus.className = 'salesforce-translation-import__status';
|
||||
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'salesforce-translation-import__status-row';
|
||||
|
||||
const fileName = document.createElement('span');
|
||||
fileName.className = 'salesforce-translation-import__file-name';
|
||||
fileName.textContent = state.targetFileName;
|
||||
summary.appendChild(fileName);
|
||||
|
||||
const details = document.createElement('span');
|
||||
const languages = [...state.translationsByLanguage.keys()].filter(Boolean).sort();
|
||||
details.textContent = state.targetHasLanguageColumn
|
||||
? 'Languages: ' + (languages.join(', ') || 'none')
|
||||
: 'No language column (single-source mode)';
|
||||
summary.appendChild(details);
|
||||
targetStatus.appendChild(summary);
|
||||
|
||||
appendMetric(targetStatus, state.targetStats.totalRows, 'rows in target translation file');
|
||||
appendMetric(targetStatus, state.targetStats.withoutTarget, 'rows without a target value');
|
||||
appendMetric(targetStatus, state.targetStats.sameFieldAndTarget, 'rows where field and target are identical');
|
||||
appendMetric(targetStatus, state.targetStats.incomplete, 'incomplete rows');
|
||||
appendMetric(targetStatus, state.targetStats.duplicates, 'duplicate object/field rows');
|
||||
appendMetric(targetStatus, state.targetStats.usableUniqueRows, 'usable unique target rows', 'salesforce-translation-import__match-ok');
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
previewBody.innerHTML = '';
|
||||
|
||||
if (state.previewChanges.length === 0) {
|
||||
const emptyRow = document.createElement('tr');
|
||||
emptyRow.innerHTML = '<td colspan="6" class="salesforce-translation-import__preview-empty">Noch keine Vorschau erstellt.</td>';
|
||||
previewBody.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
|
||||
state.previewChanges.forEach((change) => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
[
|
||||
change.file,
|
||||
change.language,
|
||||
change.object,
|
||||
change.field,
|
||||
change.oldValue,
|
||||
change.newValue
|
||||
].forEach((value) => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = value;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
previewBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function collectExtractionRows() {
|
||||
const rows = [];
|
||||
|
||||
state.sourceFiles
|
||||
.filter((source) => !source.error)
|
||||
.forEach((source) => {
|
||||
const xml = parseXml(source.text);
|
||||
const units = Array.from(xml.getElementsByTagName('trans-unit'));
|
||||
|
||||
units.forEach((unit) => {
|
||||
const details = getUnitDetails(unit);
|
||||
|
||||
if (!details) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNode = unit.getElementsByTagName('source')[0];
|
||||
const targetNode = unit.getElementsByTagName('target')[0];
|
||||
|
||||
rows.push({
|
||||
object: details.objectName,
|
||||
field: details.fieldName,
|
||||
target: targetNode ? targetNode.textContent ?? '' : '',
|
||||
language: source.languageLabel,
|
||||
source: sourceNode ? sourceNode.textContent ?? '' : '',
|
||||
file: source.name
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function downloadCsv(filename, rows) {
|
||||
const header = ['object', 'field', 'target', 'language', 'source', 'file'];
|
||||
const lines = [
|
||||
header.map(csvEscape).join(','),
|
||||
...rows.map((row) => [
|
||||
row.object,
|
||||
row.field,
|
||||
row.target,
|
||||
row.language,
|
||||
row.source,
|
||||
row.file
|
||||
].map(csvEscape).join(','))
|
||||
];
|
||||
|
||||
const blob = new Blob(["\uFEFF" + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
|
||||
const link = document.createElement('a');
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
|
||||
link.href = downloadUrl;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
|
||||
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
|
||||
}
|
||||
|
||||
function genericSourceSelectionIsMissing() {
|
||||
return Boolean(state.targetFileName) &&
|
||||
!state.targetHasLanguageColumn &&
|
||||
state.sourceFiles.filter((entry) => !entry.error).length > 1 &&
|
||||
!state.selectedGenericSourceId;
|
||||
}
|
||||
|
||||
async function addSourceFile(name, text, displayName) {
|
||||
try {
|
||||
const xml = parseXml(text);
|
||||
const language = getTargetLanguage(xml);
|
||||
|
||||
state.sourceFiles.push({
|
||||
id: 'source-' + (++state.sourceSequence),
|
||||
name: name,
|
||||
displayName: displayName || name,
|
||||
text: text,
|
||||
language: language.normalized,
|
||||
languageLabel: language.raw || 'Not specified',
|
||||
unitCount: xml.getElementsByTagName('trans-unit').length
|
||||
});
|
||||
} catch (error) {
|
||||
state.sourceFiles.push({
|
||||
name: name,
|
||||
displayName: displayName || name,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSourceZip(file) {
|
||||
try {
|
||||
const archive = await window.JSZip.loadAsync(await file.arrayBuffer());
|
||||
const entries = Object.values(archive.files).filter((entry) => !entry.dir && /\.(xml|xlf|xliff)$/i.test(entry.name));
|
||||
|
||||
if (entries.length === 0) {
|
||||
state.sourceFiles.push({
|
||||
name: file.name,
|
||||
error: 'The ZIP does not contain any XML or XLIFF files.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
await addSourceFile(entry.name, await entry.async('string'), file.name + ' / ' + entry.name);
|
||||
}
|
||||
} catch (error) {
|
||||
state.sourceFiles.push({
|
||||
name: file.name,
|
||||
error: 'The ZIP could not be read: ' + error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSourceFiles() {
|
||||
state.sourceFiles = [];
|
||||
state.selectedGenericSourceId = '';
|
||||
state.sourceSequence = 0;
|
||||
state.previewChanges = [];
|
||||
renderPreview();
|
||||
statsNode.textContent = '';
|
||||
|
||||
for (const file of xmlInput.files) {
|
||||
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||
await loadSourceZip(file);
|
||||
} else {
|
||||
await addSourceFile(file.name, await file.text());
|
||||
}
|
||||
}
|
||||
|
||||
renderFileStatus();
|
||||
}
|
||||
|
||||
async function loadTargetTranslations(event) {
|
||||
const file = event.target.files[0];
|
||||
|
||||
state.translationsByLanguage.clear();
|
||||
state.targetFileName = '';
|
||||
state.targetRowCount = 0;
|
||||
state.targetHasLanguageColumn = false;
|
||||
state.targetStats = createEmptyTargetStats();
|
||||
resetObjectSelect();
|
||||
|
||||
if (!file) {
|
||||
renderFileStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const workbook = window.XLSX.read(await file.arrayBuffer());
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const data = window.XLSX.utils.sheet_to_json(sheet, {
|
||||
header: 1,
|
||||
defval: ''
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new Error('The target translation file is empty.');
|
||||
}
|
||||
|
||||
const headers = data[0].map(normalizeHeader);
|
||||
const objectIndex = findHeaderIndex(headers, ['object', 'objekt']);
|
||||
const fieldIndex = findHeaderIndex(headers, ['field', 'feld']);
|
||||
const targetIndex = findHeaderIndex(headers, ['target']);
|
||||
const languageIndex = findHeaderIndex(headers, ['language', 'sprache']);
|
||||
|
||||
if (objectIndex < 0 || fieldIndex < 0 || targetIndex < 0) {
|
||||
throw new Error('Required columns are missing. Use object, field, and target.');
|
||||
}
|
||||
|
||||
state.targetHasLanguageColumn = languageIndex >= 0;
|
||||
const objects = new Set();
|
||||
const targetRows = data.slice(1).filter((row) => row.some((value) => String(value ?? '').trim() !== ''));
|
||||
|
||||
state.targetStats.totalRows = targetRows.length;
|
||||
|
||||
for (const row of targetRows) {
|
||||
const objectName = String(row[objectIndex] ?? '').trim();
|
||||
const fieldName = String(row[fieldIndex] ?? '').trim();
|
||||
const targetValue = String(row[targetIndex] ?? '').trim();
|
||||
const language = state.targetHasLanguageColumn ? normalizeLanguage(row[languageIndex]) : '';
|
||||
|
||||
if (!objectName || !fieldName || (state.targetHasLanguageColumn && !language)) {
|
||||
state.targetStats.incomplete++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!targetValue) {
|
||||
state.targetStats.withoutTarget++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fieldName === targetValue) {
|
||||
state.targetStats.sameFieldAndTarget++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!state.translationsByLanguage.has(language)) {
|
||||
state.translationsByLanguage.set(language, new Map());
|
||||
}
|
||||
|
||||
const key = objectName + '|' + fieldName;
|
||||
const languageTranslations = state.translationsByLanguage.get(language);
|
||||
|
||||
if (languageTranslations.has(key)) {
|
||||
state.targetStats.duplicates++;
|
||||
}
|
||||
|
||||
languageTranslations.set(key, targetValue);
|
||||
objects.add(objectName);
|
||||
}
|
||||
|
||||
state.targetRowCount = [...state.translationsByLanguage.values()].reduce((total, entries) => total + entries.size, 0);
|
||||
state.targetStats.usableUniqueRows = state.targetRowCount;
|
||||
state.targetFileName = file.name;
|
||||
populateObjectSelect(objects);
|
||||
renderFileStatus();
|
||||
} catch (error) {
|
||||
state.translationsByLanguage.clear();
|
||||
targetInput.value = '';
|
||||
renderFileStatus();
|
||||
window.alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
await state.sourceLoadPromise;
|
||||
|
||||
if (state.sourceFiles.filter((entry) => !entry.error).length === 0) {
|
||||
window.alert('Select at least one valid Salesforce XLIFF source file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.translationsByLanguage.size === 0) {
|
||||
window.alert('Select a valid target translation file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (genericSourceSelectionIsMissing()) {
|
||||
window.alert('Select the target XLIFF file for this target translation.');
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedSources = state.sourceFiles.filter(getTranslationsForSource);
|
||||
|
||||
if (matchedSources.length === 0) {
|
||||
window.alert('No Salesforce XLIFF source language matches the target translation data.');
|
||||
return;
|
||||
}
|
||||
|
||||
state.previewChanges = [];
|
||||
const selectedObject = objectSelect.value;
|
||||
const allTargetRows = new Set();
|
||||
const rowsFoundInSource = new Set();
|
||||
const rowsSameAsSource = new Set();
|
||||
const rowsSameAsTarget = new Set();
|
||||
const rowsToCreate = new Set();
|
||||
const rowsToReplace = new Set();
|
||||
|
||||
state.translationsByLanguage.forEach((entries, language) => {
|
||||
entries.forEach((value, key) => {
|
||||
allTargetRows.add(language + '|' + key);
|
||||
});
|
||||
});
|
||||
|
||||
for (const source of matchedSources) {
|
||||
const xml = parseXml(source.text);
|
||||
const match = getTranslationsForSource(source);
|
||||
const units = xml.getElementsByTagName('trans-unit');
|
||||
|
||||
for (const unit of units) {
|
||||
const details = getUnitDetails(unit);
|
||||
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = details.objectName + '|' + details.fieldName;
|
||||
|
||||
if (!match.translations.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rowToken = match.languageKey + '|' + key;
|
||||
rowsFoundInSource.add(rowToken);
|
||||
|
||||
const targetNode = unit.getElementsByTagName('target')[0];
|
||||
const sourceNode = unit.getElementsByTagName('source')[0];
|
||||
const oldValue = targetNode ? targetNode.textContent : '';
|
||||
const sourceValue = sourceNode ? sourceNode.textContent : '';
|
||||
const newValue = match.translations.get(key);
|
||||
|
||||
if (sourceValue.trim() === newValue) {
|
||||
rowsSameAsSource.add(rowToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetNode && oldValue.trim() === newValue) {
|
||||
rowsSameAsTarget.add(rowToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetNode) {
|
||||
rowsToReplace.add(rowToken);
|
||||
} else {
|
||||
rowsToCreate.add(rowToken);
|
||||
}
|
||||
|
||||
if (selectedObject && details.objectName !== selectedObject) {
|
||||
continue;
|
||||
}
|
||||
|
||||
state.previewChanges.push({
|
||||
file: source.name,
|
||||
language: source.languageLabel,
|
||||
object: details.objectName,
|
||||
field: details.fieldName,
|
||||
oldValue: oldValue,
|
||||
newValue: newValue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rowsNotInSource = [...allTargetRows].filter((rowToken) => !rowsFoundInSource.has(rowToken)).length;
|
||||
const translationsToApply = new Set([...rowsToCreate, ...rowsToReplace]).size;
|
||||
|
||||
renderPreview();
|
||||
statsNode.innerHTML =
|
||||
'<strong>Salesforce source comparison</strong><br>' +
|
||||
escapeHtml(rowsFoundInSource.size) + ' target rows found in matched source files<br>' +
|
||||
escapeHtml(rowsNotInSource) + ' target rows not present in matched source files<br>' +
|
||||
escapeHtml(rowsSameAsSource.size) + ' target rows identical to the XLIFF source value<br>' +
|
||||
escapeHtml(rowsSameAsTarget.size) + ' target rows identical to the current XLIFF target value<br>' +
|
||||
escapeHtml(translationsToApply) + ' translations to apply<br>' +
|
||||
escapeHtml(rowsToReplace.size) + ' existing targets to replace<br>' +
|
||||
escapeHtml(rowsToCreate.size) + ' new targets to create';
|
||||
}
|
||||
|
||||
async function exportZip() {
|
||||
await state.sourceLoadPromise;
|
||||
|
||||
if (state.sourceFiles.filter((entry) => !entry.error).length === 0) {
|
||||
window.alert('Select at least one valid Salesforce XLIFF source file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.translationsByLanguage.size === 0) {
|
||||
window.alert('Select a valid target translation file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (genericSourceSelectionIsMissing()) {
|
||||
window.alert('Select the target XLIFF file for this target translation.');
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedSources = state.sourceFiles.filter(getTranslationsForSource);
|
||||
|
||||
if (matchedSources.length === 0) {
|
||||
window.alert('No Salesforce XLIFF source language matches the target translation data.');
|
||||
return;
|
||||
}
|
||||
|
||||
const zip = new window.JSZip();
|
||||
let exportedFileCount = 0;
|
||||
|
||||
for (const source of matchedSources) {
|
||||
const xml = parseXml(source.text);
|
||||
const match = getTranslationsForSource(source);
|
||||
const units = Array.from(xml.getElementsByTagName('trans-unit'));
|
||||
let appliedTranslationCount = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const details = getUnitDetails(unit);
|
||||
const key = details ? details.objectName + '|' + details.fieldName : '';
|
||||
|
||||
if (!details || !match.translations.has(key)) {
|
||||
removeUnit(unit);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newValue = match.translations.get(key);
|
||||
let targetNode = unit.getElementsByTagName('target')[0];
|
||||
const sourceNode = unit.getElementsByTagName('source')[0];
|
||||
const sourceValue = sourceNode ? sourceNode.textContent : '';
|
||||
|
||||
if (sourceValue.trim() === newValue || (targetNode && targetNode.textContent.trim() === newValue)) {
|
||||
removeUnit(unit);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!targetNode) {
|
||||
targetNode = xml.createElement('target');
|
||||
unit.appendChild(targetNode);
|
||||
}
|
||||
|
||||
targetNode.textContent = newValue;
|
||||
appliedTranslationCount++;
|
||||
}
|
||||
|
||||
if (appliedTranslationCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
zip.file(source.name, new XMLSerializer().serializeToString(xml));
|
||||
exportedFileCount++;
|
||||
}
|
||||
|
||||
if (exportedFileCount === 0) {
|
||||
window.alert('No matching translations were found for the selected Salesforce XLIFF export files.');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const link = document.createElement('a');
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
|
||||
link.href = downloadUrl;
|
||||
link.download = 'salesforce_translated_xliff.zip';
|
||||
link.click();
|
||||
|
||||
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
|
||||
}
|
||||
|
||||
async function extractCsv() {
|
||||
await state.sourceLoadPromise;
|
||||
|
||||
if (state.sourceFiles.filter((entry) => !entry.error).length === 0) {
|
||||
window.alert('Select at least one valid Salesforce XLIFF source file.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = collectExtractionRows();
|
||||
|
||||
if (rows.length === 0) {
|
||||
window.alert('No translatable Salesforce rows were found in the selected XLIFF files.');
|
||||
return;
|
||||
}
|
||||
|
||||
downloadCsv('salesforce_translation_template.csv', rows);
|
||||
}
|
||||
|
||||
xmlInput.addEventListener('change', () => {
|
||||
state.sourceLoadPromise = loadSourceFiles();
|
||||
});
|
||||
targetInput.addEventListener('change', loadTargetTranslations);
|
||||
genericSourceSelect.addEventListener('change', (event) => {
|
||||
state.selectedGenericSourceId = event.target.value;
|
||||
renderFileStatus();
|
||||
});
|
||||
analyzeBtn.addEventListener('click', analyze);
|
||||
exportBtn.addEventListener('click', exportZip);
|
||||
extractBtn.addEventListener('click', extractCsv);
|
||||
|
||||
renderFileStatus();
|
||||
renderPreview();
|
||||
|
||||
if (options && options.standalone) {
|
||||
document.body.classList.add('salesforce-translation-import-page');
|
||||
}
|
||||
}
|
||||
|
||||
window.initSalesforceTranslationImportApp = function initSalesforceTranslationImportApp(host, options) {
|
||||
if (!(host instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.XLSX || !window.JSZip) {
|
||||
host.innerHTML = '<div class="salesforce-translation-import"><div class="salesforce-translation-import__card"><h2>Abhaengigkeiten fehlen</h2><p>Die benoetigten Bibliotheken fuer Excel- und ZIP-Verarbeitung konnten nicht geladen werden.</p></div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
buildApp(host, options || {});
|
||||
};
|
||||
}());
|
||||
39
custom/apps/salesforce-translation-import/desktop.php
Normal file
39
custom/apps/salesforce-translation-import/desktop.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$manifest = [];
|
||||
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
|
||||
if ($manifestRaw !== false) {
|
||||
$decoded = json_decode($manifestRaw, true);
|
||||
$manifest = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return [
|
||||
'app_id' => 'salesforce-translation-import',
|
||||
'title' => (string) ($manifest['title'] ?? 'Salesforce Translation Import'),
|
||||
'icon' => 'SF',
|
||||
'entry_route' => '/apps/salesforce-translation-import',
|
||||
'window_mode' => 'window',
|
||||
'content_mode' => 'native-module',
|
||||
'default_width' => 1180,
|
||||
'default_height' => 820,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => false,
|
||||
'module_name' => 'integration',
|
||||
'summary' => (string) ($manifest['description'] ?? 'Import-Werkzeug fuer Salesforce-XLIFF-Dateien und Zieluebersetzungen aus Excel oder CSV.'),
|
||||
'native_module' => [
|
||||
'initializer' => 'initSalesforceTranslationImportApp',
|
||||
'options' => [],
|
||||
'assets' => [
|
||||
'styles' => [
|
||||
['href' => '/module-assets/index.php?module=salesforce-translation-import&path=assets/css/app.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/css/app.css') ? filemtime(__DIR__ . '/assets/css/app.css') : '0'))],
|
||||
],
|
||||
'scripts' => [
|
||||
['src' => 'https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js'],
|
||||
['src' => 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js'],
|
||||
['src' => '/module-assets/index.php?module=salesforce-translation-import&path=assets/js/app.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/js/app.js') ? filemtime(__DIR__ . '/assets/js/app.js') : '0'))],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
14
custom/apps/salesforce-translation-import/docs/README.md
Normal file
14
custom/apps/salesforce-translation-import/docs/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Salesforce Translation Import
|
||||
|
||||
Desktop-App fuer den Import von Salesforce-XLIFF-Dateien mit Zieluebersetzungen aus Excel oder CSV.
|
||||
|
||||
## Einbindung
|
||||
|
||||
- Desktop-App ueber `custom/apps/salesforce-translation-import/desktop.php`
|
||||
- oeffentlicher App-Einstieg ueber `public/apps/salesforce-translation-import/index.php`
|
||||
- Darstellung als `native-module` mit modul-eigenem `app.js` und `app.css`
|
||||
|
||||
## Hinweis
|
||||
|
||||
- die bestehende HTML-/JS-Logik aus `Customimport/salesforcelanguageimport.html` wurde in modul-eigene Assets ueberfuehrt
|
||||
- die App nutzt weiterhin nur clientseitige Verarbeitung fuer Excel-, CSV-, ZIP- und XLIFF-Dateien
|
||||
12
custom/apps/salesforce-translation-import/module.json
Normal file
12
custom/apps/salesforce-translation-import/module.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "salesforce-translation-import",
|
||||
"title": "Salesforce Translation Import",
|
||||
"version": "0.1.0",
|
||||
"description": "Import-Werkzeug fuer Salesforce-XLIFF-Dateien und Zieluebersetzungen aus Excel oder CSV.",
|
||||
"enabled_by_default": true,
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user