From 91dc84d0271e19b07d636d5a65f1add0505798b9 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Wed, 22 Apr 2026 01:31:18 +0200 Subject: [PATCH] boerse --- .../boersenchecker/assets/boersenchecker.css | 135 ++++++++ .../boersenchecker/assets/boersenchecker.js | 146 ++++++++ modules/boersenchecker/bootstrap.php | 145 ++++++++ modules/boersenchecker/module.json | 8 +- .../boersenchecker/pages/aktienverwaltung.php | 13 + modules/boersenchecker/pages/asset.php | 30 ++ modules/boersenchecker/pages/chart_data.php | 53 +++ .../boersenchecker/pages/depotverwaltung.php | 13 + modules/boersenchecker/pages/index.php | 10 +- modules/boersenchecker/partials/dashboard.php | 44 ++- modules/boersenchecker/partials/home.php | 147 +++++++++ .../boersenchecker/partials/instruments.php | 161 +++++++++ .../src/Support/DashboardPage.php | 125 +++---- .../boersenchecker/src/Support/HomePage.php | 252 ++++++++++++++ .../src/Support/InstrumentPage.php | 311 ++++++++++++++++++ .../src/Support/InstrumentRegistry.php | 190 +++++++++++ 16 files changed, 1697 insertions(+), 86 deletions(-) create mode 100644 modules/boersenchecker/assets/boersenchecker.css create mode 100644 modules/boersenchecker/assets/boersenchecker.js create mode 100644 modules/boersenchecker/pages/aktienverwaltung.php create mode 100644 modules/boersenchecker/pages/asset.php create mode 100644 modules/boersenchecker/pages/chart_data.php create mode 100644 modules/boersenchecker/pages/depotverwaltung.php create mode 100644 modules/boersenchecker/partials/home.php create mode 100644 modules/boersenchecker/partials/instruments.php create mode 100644 modules/boersenchecker/src/Support/HomePage.php create mode 100644 modules/boersenchecker/src/Support/InstrumentPage.php create mode 100644 modules/boersenchecker/src/Support/InstrumentRegistry.php diff --git a/modules/boersenchecker/assets/boersenchecker.css b/modules/boersenchecker/assets/boersenchecker.css new file mode 100644 index 0000000..11b4994 --- /dev/null +++ b/modules/boersenchecker/assets/boersenchecker.css @@ -0,0 +1,135 @@ +.bc-hero { + background: + radial-gradient(circle at top right, rgba(71, 169, 255, 0.22), transparent 32%), + radial-gradient(circle at bottom left, rgba(81, 214, 141, 0.18), transparent 28%), + linear-gradient(145deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)); +} + +.bc-toolbar, +.bc-card-grid { + display: grid; + gap: 14px; +} + +.bc-toolbar { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.bc-card-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.bc-stat { + padding: 16px; + border-radius: 18px; + background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); + border: 1px solid rgba(255,255,255,0.08); +} + +.bc-stat-value { + margin-top: 6px; + font-size: 1.35rem; + font-weight: 700; +} + +.bc-surface { + padding: 18px; + border-radius: 22px; + background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); + border: 1px solid rgba(255,255,255,0.08); +} + +.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: #5eead4; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 12px 24px rgba(94, 234, 212, 0.25)); +} + +.bc-chart-area { + fill: url(#bc-chart-fill); +} + +.bc-chart-grid line { + stroke: rgba(255,255,255,0.08); + stroke-dasharray: 4 6; +} + +.bc-range-list, +.bc-inline-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.bc-range-button { + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.04); + color: var(--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: rgba(94, 234, 212, 0.12); + border-color: rgba(94, 234, 212, 0.45); +} + +.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: 12px; +} + +.bc-position-row { + display: grid; + grid-template-columns: minmax(0, 1.6fr) repeat(3, minmax(100px, .8fr)); + gap: 12px; + align-items: center; + padding: 14px 16px; + border-radius: 16px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.06); +} + +.bc-pill-soft { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: rgba(255,255,255,0.06); + color: var(--muted); + font-size: .85rem; +} + +@media (max-width: 900px) { + .bc-position-row { + grid-template-columns: 1fr; + } +} diff --git a/modules/boersenchecker/assets/boersenchecker.js b/modules/boersenchecker/assets/boersenchecker.js new file mode 100644 index 0000000..5a098aa --- /dev/null +++ b/modules/boersenchecker/assets/boersenchecker.js @@ -0,0 +1,146 @@ +(function () { + const app = document.querySelector('[data-bc-home]'); + if (!app) return; + + 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-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 = '
Keine Chartdaten verfuegbar.
'; + 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 ``; + }).join(''); + + chartShell.innerHTML = ` + + + + + + + + ${grid} + + + + `; + + 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 = '
Keine Chartdaten verfuegbar.
'; + return; + } + if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...'; + try { + const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, { headers: { Accept: 'application/json' } }); + const payload = await response.json(); + if (!payload.ok) { + throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.'); + } + currentPayload = payload; + renderChart(pointsForRange(payload, activeRange)); + if (statusNode) statusNode.textContent = `Quelle: Alpha Vantage | Symbol ${payload.symbol || ''}`; + } catch (error) { + currentPayload = null; + chartShell.innerHTML = `
${error.message}
`; + 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(); +})(); diff --git a/modules/boersenchecker/bootstrap.php b/modules/boersenchecker/bootstrap.php index 9a31fce..7dbbc90 100644 --- a/modules/boersenchecker/bootstrap.php +++ b/modules/boersenchecker/bootstrap.php @@ -536,3 +536,148 @@ $mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static functi 'results' => $results, ]; }); + +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_chart_series', static function (string $symbol): array { + $settings = modules()->settings('boersenchecker'); + $apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? '')); + $timeout = (int) ($settings['alpha_vantage_timeout_sec'] ?? 12); + $timeout = $timeout > 0 ? $timeout : 12; + $symbol = strtoupper(trim($symbol)); + + if ($symbol === '') { + return ['ok' => false, 'message' => 'Kein Symbol angegeben.']; + } + if ($apiKey === '') { + return ['ok' => false, 'message' => 'Alpha-Vantage-API-Key fehlt.']; + } + + $cacheDir = sys_get_temp_dir() . '/boersenchecker-alpha-vantage'; + if (!is_dir($cacheDir)) { + @mkdir($cacheDir, 0775, true); + } + + $fetchPayload = static function (string $functionName, int $ttl) use ($symbol, $apiKey, $timeout, $cacheDir): array { + $cacheKey = md5($functionName . '|' . $symbol . '|' . $apiKey); + $cachePath = $cacheDir . '/' . $cacheKey . '.json'; + if (is_file($cachePath) && (time() - filemtime($cachePath)) < $ttl) { + $cached = file_get_contents($cachePath); + $decoded = is_string($cached) ? json_decode($cached, true) : null; + if (is_array($decoded)) { + return $decoded; + } + } + + $url = 'https://www.alphavantage.co/query?' . http_build_query([ + 'function' => $functionName, + 'symbol' => $symbol, + 'apikey' => $apiKey, + 'outputsize' => 'compact', + ]); + + $responseBody = null; + 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); + 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); + } + + $decoded = is_string($responseBody) ? json_decode($responseBody, true) : null; + if (is_array($decoded) && $decoded !== []) { + @file_put_contents($cachePath, json_encode($decoded, JSON_UNESCAPED_UNICODE)); + return $decoded; + } + + return []; + }; + + $normalizeSeries = static function (array $payload, array $keys): array { + foreach ($keys as $key) { + $series = $payload[$key] ?? null; + if (!is_array($series)) { + continue; + } + + $points = []; + foreach ($series as $date => $row) { + if (!is_array($row)) { + continue; + } + $close = $row['4. close'] ?? $row['5. adjusted close'] ?? null; + if (!is_numeric($close)) { + $close = $row['5. adjusted close'] ?? $row['4. close'] ?? null; + } + if (!is_numeric($close)) { + continue; + } + $points[] = [ + 'date' => (string) $date, + 'close' => (float) $close, + ]; + } + + usort($points, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); + return $points; + } + + return []; + }; + + $dailyPayload = $fetchPayload('TIME_SERIES_DAILY_ADJUSTED', 6 * 3600); + if (($dailyPayload['Information'] ?? null) || ($dailyPayload['Error Message'] ?? null)) { + $dailyPayload = $fetchPayload('TIME_SERIES_DAILY', 6 * 3600); + } + $weeklyPayload = $fetchPayload('TIME_SERIES_WEEKLY_ADJUSTED', 12 * 3600); + if (($weeklyPayload['Information'] ?? null) || ($weeklyPayload['Error Message'] ?? null)) { + $weeklyPayload = $fetchPayload('TIME_SERIES_WEEKLY', 12 * 3600); + } + $monthlyPayload = $fetchPayload('TIME_SERIES_MONTHLY_ADJUSTED', 24 * 3600); + if (($monthlyPayload['Information'] ?? null) || ($monthlyPayload['Error Message'] ?? null)) { + $monthlyPayload = $fetchPayload('TIME_SERIES_MONTHLY', 24 * 3600); + } + + if (!empty($dailyPayload['Note']) || !empty($weeklyPayload['Note']) || !empty($monthlyPayload['Note'])) { + return [ + 'ok' => false, + 'message' => 'Alpha Vantage Limit erreicht. Bitte spaeter erneut versuchen.', + ]; + } + + $daily = $normalizeSeries($dailyPayload, ['Time Series (Daily)']); + $weekly = $normalizeSeries($weeklyPayload, ['Weekly Adjusted Time Series', 'Weekly Time Series']); + $monthly = $normalizeSeries($monthlyPayload, ['Monthly Adjusted Time Series', 'Monthly Time Series']); + + if ($daily === [] && $weekly === [] && $monthly === []) { + return [ + 'ok' => false, + 'message' => 'Keine Zeitreihendaten fuer ' . $symbol . ' verfuegbar.', + ]; + } + + return [ + 'ok' => true, + 'symbol' => $symbol, + 'daily' => $daily, + 'weekly' => $weekly, + 'monthly' => $monthly, + ]; +}); diff --git a/modules/boersenchecker/module.json b/modules/boersenchecker/module.json index 74b7802..9e6f17a 100644 --- a/modules/boersenchecker/module.json +++ b/modules/boersenchecker/module.json @@ -4,14 +4,18 @@ "description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.", "enabled_by_default": false, "menu": [ - { "label": "Uebersicht", "href": "/module/boersenchecker" } + { "label": "Startseite", "href": "/module/boersenchecker" }, + { "label": "Depotverwaltung", "href": "/module/boersenchecker/depotverwaltung" }, + { "label": "Aktienverwaltung", "href": "/module/boersenchecker/aktienverwaltung" } ], "sidebar": { "enabled": true, "collapsible": true, "default": "collapsed", "items": [ - { "label": "Uebersicht", "href": "/module/boersenchecker" } + { "label": "Startseite", "href": "/module/boersenchecker" }, + { "label": "Depotverwaltung", "href": "/module/boersenchecker/depotverwaltung" }, + { "label": "Aktienverwaltung", "href": "/module/boersenchecker/aktienverwaltung" } ] }, "setup": { diff --git a/modules/boersenchecker/pages/aktienverwaltung.php b/modules/boersenchecker/pages/aktienverwaltung.php new file mode 100644 index 0000000..5b68981 --- /dev/null +++ b/modules/boersenchecker/pages/aktienverwaltung.php @@ -0,0 +1,13 @@ +assets(); +if ($assets) { + $assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css'); + $assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true); +} + +$page = new \Modules\Boersenchecker\Support\InstrumentPage(); +module_tpl('boersenchecker', 'instruments', $page->handle()); diff --git a/modules/boersenchecker/pages/asset.php b/modules/boersenchecker/pages/asset.php new file mode 100644 index 0000000..e47e882 --- /dev/null +++ b/modules/boersenchecker/pages/asset.php @@ -0,0 +1,30 @@ + $base . '/boersenchecker.css', + 'boersenchecker.js' => $base . '/boersenchecker.js', +]; + +if (!isset($map[$file])) { + http_response_code(404); + exit('Not found'); +} + +$path = $map[$file]; +if (!$base || !is_file($path) || !str_starts_with($path, $base)) { + http_response_code(404); + exit('Not found'); +} + +$ext = pathinfo($path, PATHINFO_EXTENSION); +if ($ext === 'css') { + header('Content-Type: text/css; charset=utf-8'); +} elseif ($ext === 'js') { + header('Content-Type: application/javascript; charset=utf-8'); +} else { + header('Content-Type: application/octet-stream'); +} + +readfile($path); +exit; diff --git a/modules/boersenchecker/pages/chart_data.php b/modules/boersenchecker/pages/chart_data.php new file mode 100644 index 0000000..334a3bc --- /dev/null +++ b/modules/boersenchecker/pages/chart_data.php @@ -0,0 +1,53 @@ + 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'); + +$stmt = $pdo->prepare( + 'SELECT i.id, i.name, i.symbol + 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); + +header('Content-Type: application/json; charset=utf-8'); +if (!is_array($instrument)) { + echo json_encode(['ok' => false, 'message' => 'Aktie nicht verfuegbar.'], JSON_UNESCAPED_UNICODE); + exit; +} + +$symbol = trim((string) ($instrument['symbol'] ?? '')); +if ($symbol === '') { + echo json_encode(['ok' => false, 'message' => 'Fuer diese Aktie ist kein Symbol hinterlegt.'], JSON_UNESCAPED_UNICODE); + exit; +} + +$result = module_fn('boersenchecker', 'alpha_vantage_fetch_chart_series', $symbol); +echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +exit; diff --git a/modules/boersenchecker/pages/depotverwaltung.php b/modules/boersenchecker/pages/depotverwaltung.php new file mode 100644 index 0000000..79da06a --- /dev/null +++ b/modules/boersenchecker/pages/depotverwaltung.php @@ -0,0 +1,13 @@ +assets(); +if ($assets) { + $assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css'); + $assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true); +} + +$page = new \Modules\Boersenchecker\Support\DashboardPage(); +module_tpl('boersenchecker', 'dashboard', $page->handle()); diff --git a/modules/boersenchecker/pages/index.php b/modules/boersenchecker/pages/index.php index bee9e89..fd5930a 100644 --- a/modules/boersenchecker/pages/index.php +++ b/modules/boersenchecker/pages/index.php @@ -3,5 +3,11 @@ declare(strict_types=1); require_auth(); -$page = new \Modules\Boersenchecker\Support\DashboardPage(); -module_tpl('boersenchecker', 'dashboard', $page->handle()); +$assets = app()->assets(); +if ($assets) { + $assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css'); + $assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true); +} + +$page = new \Modules\Boersenchecker\Support\HomePage(); +module_tpl('boersenchecker', 'home', $page->handle()); diff --git a/modules/boersenchecker/partials/dashboard.php b/modules/boersenchecker/partials/dashboard.php index 9db5777..20b0814 100644 --- a/modules/boersenchecker/partials/dashboard.php +++ b/modules/boersenchecker/partials/dashboard.php @@ -1,3 +1,4 @@ +
Boersenchecker

Depotverwaltung

@@ -16,11 +17,31 @@
+ +
+ Benutzer-Scope +
+ + +
+
+ +
+
Noch keine historischen Kurse vorhanden.
@@ -421,6 +450,7 @@
+
diff --git a/modules/boersenchecker/partials/home.php b/modules/boersenchecker/partials/home.php new file mode 100644 index 0000000..4329dc0 --- /dev/null +++ b/modules/boersenchecker/partials/home.php @@ -0,0 +1,147 @@ +
+ +
Boersenchecker
+

Startseite

+

Depotauswahl, Aktienauswahl und animierte Kurscharts auf Basis von Schlusskursen.

+ + +
+ +
+ + +
+ +
+ Benutzer + + +
+ + +
+ + Depot + +
Keine Depots vorhanden.
+ + + +
+ +
+ + + Aktie + +
Keine Aktien im ausgewaehlten Depot.
+ + + +
+ +
+ + + + Aktuelle Kurse +

Abruf der aktuellen Kurse fuer das gewaehlte Depot.

+ +
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+ + +
·
+ +
+
+ + + + + + + +
+
+
Chartdaten werden geladen...
+
-
+
+
+ +
+ Aktien im Depot + +
Keine Aktien im ausgewaehlten Depot.
+ +
+ +
+
+ +
·
+
+
+
Stueckzahl
+
+
+
+
Kaufpreis
+
+
+
+
Letzter Kurs
+
+
+
+ +
+ +
+
diff --git a/modules/boersenchecker/partials/instruments.php b/modules/boersenchecker/partials/instruments.php new file mode 100644 index 0000000..a734d17 --- /dev/null +++ b/modules/boersenchecker/partials/instruments.php @@ -0,0 +1,161 @@ + +
+
Boersenchecker
+

Aktienverwaltung

+

Aktien aller Depots des ausgewaehlten Benutzers bearbeiten und manuelle Kurse pflegen.

+ + +
+ +
+ + + +
+ Benutzer-Scope +
+ + +
+
+ + +
+
+ Aktie waehlen +
+ + +
+
+ +
+ Symbolsuche +
+ + + + + +
+ +
+ + + + + + + + + + + + +
+ + Uebernehmen + +
+
+ +
+
+ +
+ Aktie bearbeiten + +
Keine Aktie vorhanden.
+ +
+ + + +
+ + + + + + +
+ +
+
+ + + + +
+ +
+ +
+ Manuellen Kurs eingeben + +
Keine Aktie vorhanden.
+ +
+ + + +
+ + + + +
+ +
+ +
+ +
+ Kursverlauf + +
Keine Kursdaten vorhanden.
+ +
+ + + + + + + + + + + +
+
+ + + + + +
+
+
+ +
+
diff --git a/modules/boersenchecker/src/Support/DashboardPage.php b/modules/boersenchecker/src/Support/DashboardPage.php index 27500ea..b257ecb 100644 --- a/modules/boersenchecker/src/Support/DashboardPage.php +++ b/modules/boersenchecker/src/Support/DashboardPage.php @@ -10,6 +10,7 @@ final class DashboardPage { private PDO $pdo; private array $user; + private bool $isAdmin; private string $ownerSub; private array $moduleSettings; private string $defaultReportCurrency; @@ -21,8 +22,10 @@ final class DashboardPage private string $instrumentTable; private string $positionTable; private string $quoteTable; + private InstrumentRegistry $instrumentRegistry; private string $symbolSearchKeywords = ''; private array $symbolSearchResults = []; + private array $availableOwners = []; public function __construct() { @@ -30,7 +33,15 @@ final class DashboardPage \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); @@ -51,6 +62,12 @@ final class DashboardPage $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 @@ -78,6 +95,9 @@ final class DashboardPage return [ 'notice' => $notice, 'error' => $error, + 'isAdmin' => $this->isAdmin, + 'ownerSub' => $this->ownerSub, + 'availableOwners' => array_values($this->availableOwners), 'defaultReportCurrency' => $this->defaultReportCurrency, 'fxMaxAgeHours' => $this->fxMaxAgeHours, 'alphaMinIntervalMinutes' => $this->alphaMinIntervalMinutes, @@ -696,81 +716,7 @@ final class DashboardPage private function upsertInstrument(array $payload): int { - $instrumentId = (int) ($payload['id'] ?? 0); - $driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); - $data = [ - 'isin' => trim((string) ($payload['isin'] ?? '')) ?: null, - 'wkn' => trim((string) ($payload['wkn'] ?? '')) ?: null, - 'symbol' => trim((string) ($payload['symbol'] ?? '')) ?: null, - 'name' => trim((string) ($payload['name'] ?? '')), - 'quote_currency' => $this->normalizeCurrency((string) ($payload['quote_currency'] ?? 'EUR')), - 'market' => trim((string) ($payload['market'] ?? '')) ?: null, - ]; - - if ($data['name'] === '') { - throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.'); - } - - if ($instrumentId <= 0) { - $instrumentId = $this->findInstrumentId($payload) ?? 0; - } - - if ($instrumentId > 0) { - $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]); - return $instrumentId; - } - - 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 findInstrumentId(array $payload): ?int - { - $isin = trim((string) ($payload['isin'] ?? '')); - $symbol = trim((string) ($payload['symbol'] ?? '')); - $name = trim((string) ($payload['name'] ?? '')); - - if ($isin !== '') { - $stmt = $this->pdo->prepare('SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin LIMIT 1'); - $stmt->execute(['isin' => $isin]); - $id = $stmt->fetchColumn(); - if ($id !== false) { - return (int) $id; - } - } - - if ($symbol !== '' && $name !== '') { - $stmt = $this->pdo->prepare('SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name LIMIT 1'); - $stmt->execute([ - 'symbol' => $symbol, - 'name' => $name, - ]); - $id = $stmt->fetchColumn(); - if ($id !== false) { - return (int) $id; - } - } - - return null; + return $this->instrumentRegistry->save($payload); } private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void @@ -838,4 +784,33 @@ final class DashboardPage 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; + } } diff --git a/modules/boersenchecker/src/Support/HomePage.php b/modules/boersenchecker/src/Support/HomePage.php new file mode 100644 index 0000000..3691966 --- /dev/null +++ b/modules/boersenchecker/src/Support/HomePage.php @@ -0,0 +1,252 @@ +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; + } + } + + $settings = \modules()->settings('boersenchecker'); + $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $this->alphaMinIntervalMinutes = (int) ($settings['alpha_vantage_min_interval_minutes'] ?? 60); + if ($this->alphaMinIntervalMinutes <= 0) { + $this->alphaMinIntervalMinutes = 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'] ?? '') : ''; + } + unset($position); + + $selectedInstrument = null; + foreach ($positions as $position) { + if ((int) $position['instrument_id'] === $selectedInstrumentId) { + $selectedInstrument = $position; + break; + } + } + + return [ + 'notice' => $notice, + 'error' => $error, + 'isAdmin' => $this->isAdmin, + 'ownerSub' => $this->ownerSub, + 'availableOwners' => array_values($this->availableOwners), + 'portfolios' => $portfolios, + 'selectedPortfolioId' => $selectedPortfolioId, + 'positions' => $positions, + 'selectedInstrumentId' => $selectedInstrumentId, + 'selectedInstrument' => $selectedInstrument, + 'chartEndpoint' => '/module/boersenchecker/chart_data?owner_sub=' . urlencode($this->ownerSub), + ]; + } + + 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.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; + 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->alphaMinIntervalMinutes * 60)) { + $reused++; + continue; + } + + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote', $symbol); + if (empty($apiResult['ok'])) { + continue; + } + + $stmtInsert = $this->pdo->prepare( + 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source) + VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' + ); + $stmtInsert->execute([ + 'instrument_id' => $instrumentId, + 'price' => (float) $apiResult['price'], + 'currency' => strtoupper(trim((string) ($row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, + 'quoted_at' => (string) $apiResult['fetched_at'], + 'source' => (string) $apiResult['source'], + ]); + $updated++; + } + + return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet.'; + } + + 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, + ]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + 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' => 'alpha_vantage:%', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + 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; + } +} diff --git a/modules/boersenchecker/src/Support/InstrumentPage.php b/modules/boersenchecker/src/Support/InstrumentPage.php new file mode 100644 index 0000000..cdf3e5e --- /dev/null +++ b/modules/boersenchecker/src/Support/InstrumentPage.php @@ -0,0 +1,311 @@ +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; + } + } + + $settings = \modules()->settings('boersenchecker'); + $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $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'] ?? '')); + $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, + 'market' => $candidateMarket, + 'quote_currency' => $candidateCurrency, + 'isin' => '', + 'wkn' => '', + ]; + } + + return [ + 'notice' => $notice, + 'error' => $error, + 'isAdmin' => $this->isAdmin, + 'ownerSub' => $this->ownerSub, + 'availableOwners' => array_values($this->availableOwners), + 'instruments' => $instruments, + 'selectedInstrument' => $selectedInstrument, + 'selectedInstrumentId' => $selectedInstrumentId, + 'quotes' => $quotes, + 'searchKeywords' => $this->searchKeywords, + 'searchResults' => $this->searchResults, + 'defaultReportCurrency' => $this->defaultReportCurrency, + ]; + } + + 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_alpha_vantage_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' => date('Y-m-d H:i:s', strtotime((string) ($_POST['quoted_at'] ?? 'now')) ?: time()), + '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 = trim((string) ($instrument['symbol'] ?? '')); + if ($symbol === '') { + throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.'); + } + + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote', $symbol); + if (empty($apiResult['ok'])) { + throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); + } + + $stmtInsert = $this->pdo->prepare( + 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source) + VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' + ); + $stmtInsert->execute([ + 'instrument_id' => $instrumentId, + 'price' => (float) $apiResult['price'], + 'currency' => strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, + 'quoted_at' => (string) $apiResult['fetched_at'], + 'source' => (string) $apiResult['source'], + ]); + + return 'API-Kurs gespeichert.'; + } + + 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 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; + } + + 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 in diesem Benutzer-Scope nicht verfuegbar.'); + } + + return $instrument; + } +} diff --git a/modules/boersenchecker/src/Support/InstrumentRegistry.php b/modules/boersenchecker/src/Support/InstrumentRegistry.php new file mode 100644 index 0000000..fc8f471 --- /dev/null +++ b/modules/boersenchecker/src/Support/InstrumentRegistry.php @@ -0,0 +1,190 @@ +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; + } +}