New desktop
This commit is contained in:
17
README.md
17
README.md
@@ -1 +1,18 @@
|
||||
# desktop.kusche.berlin
|
||||
|
||||
V1-Scaffold fuer die neue Desktop-Oberflaeche von Kusche.Berlin.
|
||||
|
||||
## Struktur
|
||||
|
||||
- `public/` Web-Root mit Desktop-Shell auf `/`
|
||||
- `api/desktop.php` JSON-Payload fuer die Shell
|
||||
- `src/Desktop/` zentrale Desktop-Mechaniken
|
||||
- `partials/desktop/` Shell-Template
|
||||
- `modules/` Zielort fuer klassische Module
|
||||
- `temp/nexus-module-import/` Rohbasis fuer importierte Nexus-Module
|
||||
|
||||
## Hinweise
|
||||
|
||||
- `Old-Nexus/` wird nicht technisch eingebunden.
|
||||
- Skins `Windows`, `Apple`, `Linux` laufen auf einer gemeinsamen Shell.
|
||||
- Keycloak bleibt das Auth-System; der Theme-Handoff ist in `docs/keycloak-theme-handoff.md` beschrieben.
|
||||
|
||||
13
api/desktop.php
Normal file
13
api/desktop.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\App;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$app = new App(dirname(__DIR__));
|
||||
|
||||
echo json_encode($app->desktopPayload(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
71
config/apps.php
Normal file
71
config/apps.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
[
|
||||
'app_id' => 'welcome-center',
|
||||
'title' => 'Welcome Center',
|
||||
'icon' => 'WC',
|
||||
'entry_route' => '/',
|
||||
'window_mode' => 'window',
|
||||
'default_width' => 680,
|
||||
'default_height' => 420,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => false,
|
||||
'module_name' => 'system',
|
||||
'summary' => 'Startpunkt der Desktop-Shell mit Status zur Architektur, Skin-Umschaltung und naechsten Schritten.',
|
||||
],
|
||||
[
|
||||
'app_id' => 'public-dashboard',
|
||||
'title' => 'Public Dashboard',
|
||||
'icon' => 'PD',
|
||||
'entry_route' => '/public-home',
|
||||
'window_mode' => 'window',
|
||||
'default_width' => 540,
|
||||
'default_height' => 360,
|
||||
'supports_widget' => true,
|
||||
'supports_tray' => false,
|
||||
'module_name' => 'desktop',
|
||||
'summary' => 'Oeffentliche Startansicht fuer nicht eingeloggte Nutzer.',
|
||||
],
|
||||
[
|
||||
'app_id' => 'workspace-manager',
|
||||
'title' => 'Workspaces',
|
||||
'icon' => 'WS',
|
||||
'entry_route' => '/workspaces',
|
||||
'window_mode' => 'window',
|
||||
'default_width' => 520,
|
||||
'default_height' => 380,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => false,
|
||||
'module_name' => 'desktop',
|
||||
'summary' => 'Vorbereitung fuer mehrere persoenliche Dashboards und Workspace-Verwaltung.',
|
||||
],
|
||||
[
|
||||
'app_id' => 'admin-apps',
|
||||
'title' => 'Admin Apps',
|
||||
'icon' => 'AA',
|
||||
'entry_route' => '/admin/apps',
|
||||
'window_mode' => 'window',
|
||||
'default_width' => 560,
|
||||
'default_height' => 380,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => true,
|
||||
'module_name' => 'admin',
|
||||
'summary' => 'Platzhalter fuer globale Verwaltung von Apps, Widgets, Integrationen und Suchmaschinen.',
|
||||
],
|
||||
[
|
||||
'app_id' => 'desktop-login',
|
||||
'title' => 'Desktop Login',
|
||||
'icon' => 'DL',
|
||||
'entry_route' => '/auth/login',
|
||||
'window_mode' => 'modal',
|
||||
'default_width' => 460,
|
||||
'default_height' => 320,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => false,
|
||||
'module_name' => 'auth',
|
||||
'summary' => 'Uebergabepunkt fuer das spaetere Keycloak-Theme im Desktop-Look.',
|
||||
],
|
||||
];
|
||||
30
docs/keycloak-theme-handoff.md
Normal file
30
docs/keycloak-theme-handoff.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Keycloak Theme Handoff
|
||||
|
||||
Diese Datei beschreibt die Uebergabepunkte zwischen der Desktop-Shell und dem spaeteren Keycloak-Login-Theme.
|
||||
|
||||
## Zielbild
|
||||
|
||||
- Keycloak bleibt das Auth-System.
|
||||
- `desktop.kusche.berlin` bekommt ein eigenes Keycloak-Theme.
|
||||
- Das Login soll sich wie ein Desktop-Login anfuehlen und visuell zur Shell passen.
|
||||
|
||||
## Design-Bausteine
|
||||
|
||||
- Wallpaper oder Verlauf passend zum aktiven Branding
|
||||
- zentrierte Login-Kachel
|
||||
- grosse Uhr oder Statusanzeige
|
||||
- Logo und Produktname `Kusche.Berlin`
|
||||
- ruhige Desktop-artige Animationen
|
||||
|
||||
## Technische Schnittstellen
|
||||
|
||||
- Desktop-Shell verweist auf die Login-App `desktop-login`.
|
||||
- Keycloak-Theme liegt spaeter getrennt im Keycloak-Theme-System.
|
||||
- Gemeinsame Branding-Werte sollten aus einer zentralen Theme-Definition abgeleitet werden.
|
||||
|
||||
## Zu erledigen ausserhalb dieser V1
|
||||
|
||||
- Keycloak-Theme erzeugen
|
||||
- Theme dem passenden Client oder Realm zuweisen
|
||||
- Login-Templates, CSS und Assets im Theme pflegen
|
||||
- Ruecksprung aus Keycloak sauber in die Desktop-Shell fuehren
|
||||
5
modules/README.md
Normal file
5
modules/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Module
|
||||
|
||||
Klassische Module bleiben in diesem Projekt unter `modules/<modul>/`.
|
||||
|
||||
Die neue Desktop-Shell ist nur die UI-Schicht. Modul-Businesslogik wird nicht in den Desktop-Core verschoben.
|
||||
83
partials/desktop/shell.php
Normal file
83
partials/desktop/shell.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$payloadJson = json_encode($desktopPayload, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
|
||||
$activeSkin = $desktopPayload['meta']['active_skin'];
|
||||
$wallpaper = $desktopPayload['desktop']['wallpaper'];
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($desktopPayload['meta']['title'], ENT_QUOTES) ?></title>
|
||||
<link rel="stylesheet" href="/assets/desktop/desktop.css">
|
||||
</head>
|
||||
<body data-skin="<?= htmlspecialchars($activeSkin, ENT_QUOTES) ?>" style="--wallpaper: <?= htmlspecialchars($wallpaper, ENT_QUOTES) ?>;">
|
||||
<div class="desktop-shell">
|
||||
<aside class="desktop-sidebar">
|
||||
<div>
|
||||
<p class="eyebrow">Kusche.Berlin</p>
|
||||
<h1>Desktop UI</h1>
|
||||
<p class="lede"><?= htmlspecialchars($desktopPayload['desktop']['greeting'], ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Skins</h2>
|
||||
<div class="skin-switcher">
|
||||
<?php foreach ($desktopPayload['meta']['available_skins'] as $skin): ?>
|
||||
<a class="skin-chip<?= $skin === $activeSkin ? ' is-active' : '' ?>" href="/?skin=<?= urlencode($skin) ?>">
|
||||
<?= htmlspecialchars(ucfirst($skin), ENT_QUOTES) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>V1 Scope</h2>
|
||||
<ul class="fact-list">
|
||||
<li>Desktop-Shell auf Root</li>
|
||||
<li>Fenster-Manager vorbereitet</li>
|
||||
<li>App-Registry zentral konfiguriert</li>
|
||||
<li>Public Home und persoenliche Workspaces vorgesehen</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Keycloak</h2>
|
||||
<p>Desktop-Login bleibt Keycloak-basiert, bekommt aber ein eigenes Theme.</p>
|
||||
<a class="text-link" href="/../docs/keycloak-theme-handoff.md">Theme-Handoff ansehen</a>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="desktop-stage">
|
||||
<div class="desktop-icons" id="desktop-icons"></div>
|
||||
|
||||
<div class="widget-zone">
|
||||
<?php foreach ($desktopPayload['widgets'] as $widget): ?>
|
||||
<article class="widget-card">
|
||||
<h3><?= htmlspecialchars($widget['title'], ENT_QUOTES) ?></h3>
|
||||
<p><?= htmlspecialchars($widget['content'], ENT_QUOTES) ?></p>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="window-layer" id="window-layer"></div>
|
||||
|
||||
<footer class="taskbar">
|
||||
<button class="start-button" id="start-button" type="button">Menu</button>
|
||||
<div class="taskbar-apps" id="taskbar-apps"></div>
|
||||
<div class="tray">
|
||||
<?php foreach ($desktopPayload['tray'] as $trayItem): ?>
|
||||
<span class="tray-pill"><?= htmlspecialchars($trayItem['label'], ENT_QUOTES) ?></span>
|
||||
<?php endforeach; ?>
|
||||
<time id="clock"></time>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script id="desktop-payload" type="application/json"><?= $payloadJson ?></script>
|
||||
<script src="/assets/desktop/desktop.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
partials/landingpages/admin/apps.php
Normal file
8
partials/landingpages/admin/apps.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'title' => 'Admin Apps',
|
||||
'description' => 'Verwaltung von Apps, Widgets, Integrationen und Suchmaschinen als Desktop-App.',
|
||||
];
|
||||
8
partials/landingpages/auth/login.php
Normal file
8
partials/landingpages/auth/login.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'title' => 'Desktop Login',
|
||||
'description' => 'Spaeterer Uebergabepunkt fuer das Keycloak-Theme im Desktop-Look.',
|
||||
];
|
||||
8
partials/landingpages/public/home.php
Normal file
8
partials/landingpages/public/home.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'title' => 'Public Home Dashboard',
|
||||
'description' => 'Oeffentliche Desktop-Startansicht fuer Gaeste.',
|
||||
];
|
||||
3
partials/structure/layout_end.php
Normal file
3
partials/structure/layout_end.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
3
partials/structure/layout_start.php
Normal file
3
partials/structure/layout_start.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
332
public/assets/desktop/desktop.css
Normal file
332
public/assets/desktop/desktop.css
Normal file
@@ -0,0 +1,332 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--shell-text: #f8fafc;
|
||||
--shell-panel: rgba(15, 23, 42, 0.42);
|
||||
--shell-border: rgba(255, 255, 255, 0.16);
|
||||
--card-bg: rgba(255, 255, 255, 0.14);
|
||||
--window-bg: rgba(248, 250, 252, 0.9);
|
||||
--window-text: #0f172a;
|
||||
--accent: #60a5fa;
|
||||
--taskbar-bg: rgba(15, 23, 42, 0.8);
|
||||
--shadow: 0 16px 40px rgba(15, 23, 42, 0.35);
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--wallpaper);
|
||||
color: var(--shell-text);
|
||||
}
|
||||
|
||||
body[data-skin="apple"] {
|
||||
--accent: #fb7185;
|
||||
--taskbar-bg: rgba(45, 45, 45, 0.65);
|
||||
--window-bg: rgba(255, 251, 235, 0.92);
|
||||
}
|
||||
|
||||
body[data-skin="linux"] {
|
||||
--accent: #f97316;
|
||||
--taskbar-bg: rgba(17, 24, 39, 0.86);
|
||||
--window-bg: rgba(249, 250, 251, 0.94);
|
||||
}
|
||||
|
||||
.desktop-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.desktop-sidebar {
|
||||
padding: 32px;
|
||||
backdrop-filter: blur(20px);
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.55), rgba(15, 23, 42, 0.32));
|
||||
border-right: 1px solid var(--shell-border);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 12px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p,
|
||||
ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 40px;
|
||||
line-height: 1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 24rem;
|
||||
font-size: 16px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 24px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--shell-border);
|
||||
background: var(--shell-panel);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.skin-switcher {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.skin-chip,
|
||||
.text-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skin-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--shell-border);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.skin-chip.is-active {
|
||||
background: var(--accent);
|
||||
color: #0f172a;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.fact-list {
|
||||
padding-left: 18px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.desktop-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.desktop-icons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 16px;
|
||||
width: min(720px, calc(100% - 80px));
|
||||
}
|
||||
|
||||
.desktop-icon {
|
||||
width: 96px;
|
||||
padding: 12px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease, background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.desktop-icon:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: var(--shell-border);
|
||||
}
|
||||
|
||||
.desktop-icon-badge {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desktop-icon-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.widget-zone {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: min(320px, calc(100% - 48px));
|
||||
}
|
||||
|
||||
.widget-card {
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--shell-border);
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.window-layer {
|
||||
position: absolute;
|
||||
inset: 24px 24px 92px 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.window {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: var(--window-bg);
|
||||
color: var(--window-text);
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.3);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.window.is-minimized {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.window-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.window-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.window-action {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.window-action[data-action="close"] {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.window-action[data-action="minimize"] {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.window-action[data-action="maximize"] {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.window-content {
|
||||
padding: 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.taskbar {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 22px;
|
||||
background: var(--taskbar-bg);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid var(--shell-border);
|
||||
}
|
||||
|
||||
.start-button,
|
||||
.taskbar-app-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.taskbar-apps {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tray {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tray-pill {
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.desktop-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.desktop-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--shell-border);
|
||||
}
|
||||
|
||||
.desktop-stage {
|
||||
min-height: 70vh;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.widget-zone {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.window-layer {
|
||||
position: static;
|
||||
inset: auto;
|
||||
margin-top: 24px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.window {
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
left: auto !important;
|
||||
top: auto !important;
|
||||
}
|
||||
}
|
||||
189
public/assets/desktop/desktop.js
Normal file
189
public/assets/desktop/desktop.js
Normal file
@@ -0,0 +1,189 @@
|
||||
const payloadNode = document.getElementById('desktop-payload');
|
||||
|
||||
if (payloadNode) {
|
||||
const payload = JSON.parse(payloadNode.textContent);
|
||||
const iconsRoot = document.getElementById('desktop-icons');
|
||||
const windowsRoot = document.getElementById('window-layer');
|
||||
const taskbarRoot = document.getElementById('taskbar-apps');
|
||||
const clockNode = document.getElementById('clock');
|
||||
const windows = new Map();
|
||||
let zIndex = 20;
|
||||
|
||||
const renderClock = () => {
|
||||
if (!clockNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
clockNode.textContent = new Intl.DateTimeFormat('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(new Date());
|
||||
};
|
||||
|
||||
const focusWindow = (windowId) => {
|
||||
windows.forEach((record) => {
|
||||
record.node.classList.remove('is-focused');
|
||||
});
|
||||
|
||||
const record = windows.get(windowId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
zIndex += 1;
|
||||
record.node.style.zIndex = String(zIndex);
|
||||
record.node.classList.add('is-focused');
|
||||
};
|
||||
|
||||
const syncTaskbar = () => {
|
||||
taskbarRoot.innerHTML = '';
|
||||
|
||||
windows.forEach((record) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'taskbar-app-button';
|
||||
button.textContent = record.title;
|
||||
button.addEventListener('click', () => {
|
||||
record.node.classList.toggle('is-minimized');
|
||||
focusWindow(record.id);
|
||||
});
|
||||
taskbarRoot.appendChild(button);
|
||||
});
|
||||
};
|
||||
|
||||
const wireDragging = (record) => {
|
||||
const header = record.node.querySelector('.window-header');
|
||||
if (!header || window.matchMedia('(max-width: 1100px)').matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let originX = 0;
|
||||
let originY = 0;
|
||||
let dragging = false;
|
||||
|
||||
header.addEventListener('pointerdown', (event) => {
|
||||
dragging = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
originX = parseInt(record.node.style.left, 10);
|
||||
originY = parseInt(record.node.style.top, 10);
|
||||
header.setPointerCapture(event.pointerId);
|
||||
focusWindow(record.id);
|
||||
});
|
||||
|
||||
header.addEventListener('pointermove', (event) => {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
record.node.style.left = `${originX + event.clientX - startX}px`;
|
||||
record.node.style.top = `${originY + event.clientY - startY}px`;
|
||||
});
|
||||
|
||||
header.addEventListener('pointerup', () => {
|
||||
dragging = false;
|
||||
});
|
||||
};
|
||||
|
||||
const createWindow = (definition) => {
|
||||
const node = document.createElement('article');
|
||||
node.className = 'window';
|
||||
node.style.left = `${definition.x}px`;
|
||||
node.style.top = `${definition.y}px`;
|
||||
node.style.width = `${definition.width}px`;
|
||||
node.style.height = `${definition.height}px`;
|
||||
node.innerHTML = `
|
||||
<header class="window-header">
|
||||
<strong>${definition.title}</strong>
|
||||
<div class="window-actions">
|
||||
<button class="window-action" data-action="minimize" type="button" aria-label="Minimieren"></button>
|
||||
<button class="window-action" data-action="maximize" type="button" aria-label="Maximieren"></button>
|
||||
<button class="window-action" data-action="close" type="button" aria-label="Schliessen"></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="window-content">
|
||||
<p>${definition.content}</p>
|
||||
<p><strong>Route:</strong> ${definition.app_id}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const record = {
|
||||
id: definition.window_id,
|
||||
title: definition.title,
|
||||
node,
|
||||
definition,
|
||||
};
|
||||
|
||||
node.addEventListener('mousedown', () => focusWindow(record.id));
|
||||
|
||||
node.querySelectorAll('.window-action').forEach((action) => {
|
||||
action.addEventListener('click', () => {
|
||||
const actionName = action.dataset.action;
|
||||
if (actionName === 'close') {
|
||||
windows.delete(record.id);
|
||||
node.remove();
|
||||
syncTaskbar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionName === 'minimize') {
|
||||
node.classList.toggle('is-minimized');
|
||||
syncTaskbar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionName === 'maximize') {
|
||||
node.style.left = '24px';
|
||||
node.style.top = '24px';
|
||||
node.style.width = 'calc(100% - 48px)';
|
||||
node.style.height = 'calc(100% - 48px)';
|
||||
focusWindow(record.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
windows.set(record.id, record);
|
||||
windowsRoot.appendChild(node);
|
||||
wireDragging(record);
|
||||
focusWindow(record.id);
|
||||
syncTaskbar();
|
||||
};
|
||||
|
||||
payload.apps.forEach((app) => {
|
||||
const icon = document.createElement('button');
|
||||
icon.type = 'button';
|
||||
icon.className = 'desktop-icon';
|
||||
icon.innerHTML = `
|
||||
<span class="desktop-icon-badge">${app.icon}</span>
|
||||
<span class="desktop-icon-label">${app.title}</span>
|
||||
`;
|
||||
icon.addEventListener('click', () => {
|
||||
const existing = Array.from(windows.values()).find((record) => record.definition.app_id === app.app_id);
|
||||
|
||||
if (existing) {
|
||||
existing.node.classList.remove('is-minimized');
|
||||
focusWindow(existing.id);
|
||||
return;
|
||||
}
|
||||
|
||||
createWindow({
|
||||
window_id: `window-${app.app_id}-${Date.now()}`,
|
||||
app_id: app.app_id,
|
||||
title: app.title,
|
||||
x: 64 + Math.round(Math.random() * 80),
|
||||
y: 70 + Math.round(Math.random() * 60),
|
||||
width: app.default_width || 640,
|
||||
height: app.default_height || 420,
|
||||
content: app.summary || '',
|
||||
});
|
||||
});
|
||||
iconsRoot.appendChild(icon);
|
||||
});
|
||||
|
||||
payload.windows.forEach(createWindow);
|
||||
renderClock();
|
||||
window.setInterval(renderClock, 1000);
|
||||
}
|
||||
12
public/index.php
Normal file
12
public/index.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\App;
|
||||
|
||||
$app = new App(dirname(__DIR__));
|
||||
$desktopPayload = $app->desktopPayload();
|
||||
|
||||
require dirname(__DIR__) . '/partials/desktop/shell.php';
|
||||
51
src/App/App.php
Normal file
51
src/App/App.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Desktop\AppRegistry;
|
||||
use Desktop\DesktopState;
|
||||
use Desktop\SkinResolver;
|
||||
use Desktop\WindowManager;
|
||||
|
||||
final class App
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $projectRoot,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function desktopPayload(): array
|
||||
{
|
||||
$registry = new AppRegistry($this->projectRoot . '/config/apps.php');
|
||||
$skins = SkinResolver::all();
|
||||
$activeSkin = SkinResolver::resolve($_GET['skin'] ?? null);
|
||||
$windows = (new WindowManager($registry))->defaultWindows();
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'title' => 'Kusche.Berlin Desktop UI',
|
||||
'active_skin' => $activeSkin,
|
||||
'available_skins' => $skins,
|
||||
],
|
||||
'desktop' => [
|
||||
'greeting' => 'Neue Desktop-Shell fuer Kusche.Berlin',
|
||||
'wallpaper' => SkinResolver::wallpaper($activeSkin),
|
||||
'workspace' => DesktopState::defaultWorkspace(),
|
||||
'login' => [
|
||||
'authenticated' => false,
|
||||
'mode' => 'public-home',
|
||||
'keycloak_theme_handoff' => '/docs/keycloak-theme-handoff.md',
|
||||
],
|
||||
],
|
||||
'apps' => $registry->all(),
|
||||
'windows' => $windows,
|
||||
'widgets' => DesktopState::defaultWidgets(),
|
||||
'tray' => DesktopState::trayItems(),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
src/App/bootstrap.php
Normal file
23
src/App/bootstrap.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefixes = [
|
||||
'App\\' => __DIR__ . '/',
|
||||
'Desktop\\' => dirname(__DIR__) . '/Desktop/',
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix => $baseDir) {
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$path = $baseDir . str_replace('\\', '/', $relative) . '.php';
|
||||
|
||||
if (is_file($path)) {
|
||||
require_once $path;
|
||||
}
|
||||
}
|
||||
});
|
||||
40
src/Desktop/AppRegistry.php
Normal file
40
src/Desktop/AppRegistry.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Desktop;
|
||||
|
||||
final class AppRegistry
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private array $apps;
|
||||
|
||||
public function __construct(string $configPath)
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> $apps */
|
||||
$apps = require $configPath;
|
||||
$this->apps = $apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function find(string $appId): ?array
|
||||
{
|
||||
foreach ($this->apps as $app) {
|
||||
if (($app['app_id'] ?? null) === $appId) {
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
52
src/Desktop/DesktopState.php
Normal file
52
src/Desktop/DesktopState.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Desktop;
|
||||
|
||||
final class DesktopState
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function defaultWorkspace(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => 'default',
|
||||
'title' => 'Persoenlicher Desktop',
|
||||
'supports_public_home' => true,
|
||||
'supports_multi_workspace' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
public static function defaultWidgets(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'widget_id' => 'public-home',
|
||||
'title' => 'Public Home',
|
||||
'content' => 'Oeffentliches Dashboard als Startzustand fuer nicht eingeloggte Nutzer.',
|
||||
],
|
||||
[
|
||||
'widget_id' => 'personal-links',
|
||||
'title' => 'Persoenliche Links',
|
||||
'content' => 'Platzhalter fuer spaetere user-spezifische Linklisten und Dashboards.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
public static function trayItems(): array
|
||||
{
|
||||
return [
|
||||
['id' => 'network', 'label' => 'Netzwerk'],
|
||||
['id' => 'widgets', 'label' => 'Widgets'],
|
||||
['id' => 'account', 'label' => 'Gast'],
|
||||
];
|
||||
}
|
||||
}
|
||||
32
src/Desktop/SkinResolver.php
Normal file
32
src/Desktop/SkinResolver.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Desktop;
|
||||
|
||||
final class SkinResolver
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return ['windows', 'apple', 'linux'];
|
||||
}
|
||||
|
||||
public static function resolve(?string $requestedSkin): string
|
||||
{
|
||||
$skin = strtolower((string) $requestedSkin);
|
||||
|
||||
return in_array($skin, self::all(), true) ? $skin : 'windows';
|
||||
}
|
||||
|
||||
public static function wallpaper(string $skin): string
|
||||
{
|
||||
return match ($skin) {
|
||||
'apple' => 'radial-gradient(circle at top, #ffe7ba 0%, #ff9a62 38%, #531b53 100%)',
|
||||
'linux' => 'linear-gradient(140deg, #111827 0%, #1f2937 40%, #ea580c 100%)',
|
||||
default => 'linear-gradient(160deg, #1d4ed8 0%, #0f172a 100%)',
|
||||
};
|
||||
}
|
||||
}
|
||||
44
src/Desktop/WindowManager.php
Normal file
44
src/Desktop/WindowManager.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Desktop;
|
||||
|
||||
final class WindowManager
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AppRegistry $registry,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function defaultWindows(): array
|
||||
{
|
||||
$defaults = ['welcome-center', 'public-dashboard'];
|
||||
$windows = [];
|
||||
|
||||
foreach ($defaults as $index => $appId) {
|
||||
$app = $this->registry->find($appId);
|
||||
|
||||
if ($app === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$windows[] = [
|
||||
'window_id' => 'window-' . $appId,
|
||||
'app_id' => $appId,
|
||||
'title' => $app['title'],
|
||||
'state' => $index === 0 ? 'focused' : 'open',
|
||||
'x' => 72 + ($index * 48),
|
||||
'y' => 84 + ($index * 36),
|
||||
'width' => $app['default_width'] ?? 640,
|
||||
'height' => $app['default_height'] ?? 420,
|
||||
'content' => $app['summary'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $windows;
|
||||
}
|
||||
}
|
||||
154
temp/nexus-module-import/MODULE_DEVELOPMENT.md
Normal file
154
temp/nexus-module-import/MODULE_DEVELOPMENT.md
Normal file
@@ -0,0 +1,154 @@
|
||||
Projektkontext: Modul-Entwicklung
|
||||
|
||||
1) Modul-Konzept
|
||||
- Jedes klassische Modul lebt unter `/modules/<modulname>/`.
|
||||
- Pflicht-Dateien sind in der Regel:
|
||||
- `module.json`
|
||||
- `bootstrap.php`
|
||||
- `pages/*.php`
|
||||
- `assets/*`
|
||||
|
||||
2) Modulspezifische Assets
|
||||
- Modul-JS und Modul-CSS müssen im Modul-Ordner liegen.
|
||||
- Laden über Modul-Assets, zum Beispiel:
|
||||
- `$assets->addStyle('/module/pi_control/asset?file=pi_control.css');`
|
||||
- `$assets->addScript('/module/pi_control/asset?file=hosts.js', 'footer', true);`
|
||||
- Keine modulspezifischen Änderungen in `/public/assets/*`.
|
||||
|
||||
3) Scope-Regeln
|
||||
- Modul-Aufgaben: nur `/modules/<modul>/` und gegebenenfalls `/tools/<modul>` ändern
|
||||
- globale Layouts: `/partials/structure/` und `/public/assets/css/app.css`
|
||||
- Konfigurationslogik nur bei echtem Globalbedarf in `/config/` oder `/src/`
|
||||
|
||||
4) UI-Regeln für Module
|
||||
- Modulseiten sollen diesem Muster folgen:
|
||||
- Seitenheader-Box
|
||||
- Submenü-Box
|
||||
- danach Bereichs-Boxen und/oder Karten-Boxen
|
||||
- `Setup` gehört in Modulen grundsätzlich in die Submenü-Box
|
||||
- die Optik von Submenü-Aktionen kommt ausschließlich aus dem globalen CSS
|
||||
- Module dürfen dort keine eigenen Farb- oder Variantenlogiken einschleusen
|
||||
|
||||
5) Globales Setup-System
|
||||
- Modul-Setup wird zentral über `partials/landingpages/modules/setup.php` gerendert.
|
||||
- Die Bereiche
|
||||
- `Allgemein`
|
||||
- `Datenbank`
|
||||
- `Zugriffsrechte`
|
||||
- `Cron Einstellungen`
|
||||
müssen für alle Module aus dieser gemeinsamen Setup-Logik kommen.
|
||||
- Nur `Custom Settings` darf modulspezifischen Inhalt enthalten.
|
||||
- Modul-spezifische Sonderlayouts für die Bereiche `Allgemein`, `Datenbank`, `Zugriffsrechte` oder `Cron Einstellungen` sind nicht erlaubt.
|
||||
|
||||
Was global im Setup bereits verfügbar ist:
|
||||
- gemeinsame Setup-Navigation mit festen Unterseiten
|
||||
- rechte Aktionsseite mit
|
||||
- `Nexus Übersicht`
|
||||
- `Zurück zum Modul`
|
||||
- gemeinsames Speichern pro Bereich
|
||||
- gemeinsames Rendering von
|
||||
- Textfeldern
|
||||
- Zahlenfeldern
|
||||
- Checkboxen
|
||||
- Selects
|
||||
- Multiselects
|
||||
- Textareas
|
||||
- globale `Debug aktivieren`-Option pro Modul im Bereich `Allgemein`
|
||||
- gemeinsame Datenbank-Logik mit
|
||||
- `Eigene Modul-DB nutzen`
|
||||
- Anzeige oder Verbergen der Custom-DB-Felder
|
||||
- optional mehreren DB-Gruppen wie `db.*` und `metadata_db.*`
|
||||
- `Verbindung testen`
|
||||
- `Standardwerte laden`
|
||||
- gemeinsame Auth- und Zugriffslogik mit
|
||||
- `Login erforderlich`
|
||||
- erlaubten Benutzern
|
||||
- erlaubten Gruppen
|
||||
- bekannten Keycloak-Benutzern und -Gruppen
|
||||
- gemeinsame Cron- und Scheduler-Logik mit
|
||||
- Anzeige von Intervall-Tasks
|
||||
- Anzeige von Cron-Jobs
|
||||
- Bearbeiten von Cron-Einträgen im Modal
|
||||
- Cron-Test direkt aus dem Setup
|
||||
- Mehrfacheinträgen bei `mode = multi`
|
||||
- Modul-Zeitzonen-Override für Crons
|
||||
- Vererbung globaler Cron-Zeitzonen-Defaults
|
||||
- gemeinsame Darstellung von Setup-Aktionen und Statusblöcken
|
||||
- globale Zeitzonen-Datalist aus `nexus_timezones`
|
||||
|
||||
6) Was ein Modul für das Setup liefern darf
|
||||
- `module.json` mit `setup.fields`
|
||||
- optional `setup.sections.database`
|
||||
- optional `interval_tasks`
|
||||
- optional `scheduler_jobs`
|
||||
- optional `auth`
|
||||
- optional Bootstrap-Funktionen:
|
||||
- `setup_actions`
|
||||
- `run_setup_action`
|
||||
- `setup_status`
|
||||
- `runtime_settings`
|
||||
- `save_runtime_settings`
|
||||
|
||||
7) Was ein Modul nicht selbst bauen darf
|
||||
- eigene Setup-Seitenstruktur für `Allgemein`, `Datenbank`, `Zugriffsrechte`, `Cron Einstellungen`
|
||||
- eigene DB-Toggle-Logik für Standard und Custom
|
||||
- eigene Cron-Editor-Grundlogik
|
||||
- eigene Debug-UI-Grundlogik
|
||||
- eigene globale Zeitzonen-Defaults
|
||||
|
||||
8) Setup-Navigation
|
||||
- Setup-Routen laufen zentral über:
|
||||
- `/modules/setup/<modul>/general`
|
||||
- `/modules/setup/<modul>/database`
|
||||
- `/modules/setup/<modul>/access`
|
||||
- `/modules/setup/<modul>/cron`
|
||||
- `/modules/setup/<modul>/custom`
|
||||
- `Setup` gehört in der Modulansicht in die rechte Aktionsseite der Submenü-Box
|
||||
|
||||
9) Steuerung per `module.json`
|
||||
- Ein Modul kann über `setup.sections.database: true|false` steuern, ob der Menüpunkt `Datenbank` angezeigt wird.
|
||||
- Wenn `setup.sections.database` fehlt, kann die zentrale Setup-Logik den Punkt implizit aktivieren, sobald DB-Felder vorhanden sind.
|
||||
- Modulfelder für `Allgemein`, `Datenbank`, `Cron` und `Custom Settings` werden zentral nach Feldnamen aufgeteilt:
|
||||
- `debug_enabled` -> `Allgemein`
|
||||
- `use_separate_db` und `db.*` oder `metadata_db.*` -> `Datenbank`
|
||||
- `schedule_timezone` -> `Cron Einstellungen`
|
||||
- alle übrigen Setup-Felder -> `Custom Settings`
|
||||
|
||||
Weitere anerkannte Setup-Bausteine:
|
||||
- `interval_tasks`
|
||||
- `scheduler_jobs`
|
||||
- `auth`
|
||||
- `db_defaults`
|
||||
- `metadata_db_defaults`
|
||||
|
||||
10) Speicherregel
|
||||
- Beim Speichern eines Setup-Bereichs dürfen nur die in diesem Bereich sichtbaren Felder aktualisiert werden.
|
||||
- Felder aus anderen Bereichen dürfen nicht mit `null`, `0` oder leeren Strings überschrieben werden.
|
||||
|
||||
11) Datenbankbereich
|
||||
- `Eigene Modul-DB nutzen` ist der zentrale Standard-Schalter für Module mit optionaler eigener DB.
|
||||
- Wenn der Schalter deaktiviert ist, dürfen keine Custom-DB-Eingabefelder sichtbar sein.
|
||||
- Datenbankaktionen und Tabellenstatus gehören in den Menüpunkt `Datenbank`, nicht in `Custom Settings`.
|
||||
|
||||
12) Globale PHP-Helfer für Module
|
||||
- Neue Module sollen für zentrale Zeit- und Debug-Defaults nach Möglichkeit die globalen Funktionen aus `src/App/functions.php` nutzen:
|
||||
- `nexus_settings()`
|
||||
- `nexus_save_settings(array $settings)`
|
||||
- `nexus_system_timezone_name()`
|
||||
- `nexus_display_timezone_name()`
|
||||
- `nexus_cron_timezone_name()`
|
||||
- `module_debug_enabled(string $module)`
|
||||
- `module_debug_push(string $module, array $entry)`
|
||||
- `module_debug_clear(string $module)`
|
||||
|
||||
13) Regeln für neue Module
|
||||
- Keine Zeitzone wie `Europe/Berlin` hart im Modul als Standard erzwingen, wenn dafür ein globaler Nexus-Default existiert.
|
||||
- Für Anzeige- und Formatierungslogik nach Möglichkeit `nexus_display_timezone_name()` nutzen.
|
||||
- Für Cron-Fallbacks nach Möglichkeit `nexus_cron_timezone_name()` nutzen.
|
||||
- Neue Module dürfen keine lokalen Zeitzonen direkt in Datenbank-Zeitspalten persistieren.
|
||||
|
||||
14) Pi-Control-Besonderheiten
|
||||
- Worker und Jobs unter `/tools/pi_control/`
|
||||
- Check-Updates und Cron nutzen die gleichen SSH-Routinen
|
||||
- Host-Karten, Befehle und Konsole sind UI im Modul
|
||||
- Update- und Upgrade-Checks liefern Debug-Ausgaben, die als Tooltip oder Debugzeile angezeigt werden
|
||||
119
temp/nexus-module-import/NEXUS_SYSTEM.md
Normal file
119
temp/nexus-module-import/NEXUS_SYSTEM.md
Normal file
@@ -0,0 +1,119 @@
|
||||
Projektkontext: Nexus-System
|
||||
|
||||
1) Projekt-Zweck und Ziel
|
||||
- Nexus ist ein zentrales, webbasiertes Admin-Interface zur Steuerung einer dynamischen IT-Infrastruktur.
|
||||
- Module kapseln fachliche Funktionen, das Nexus-System stellt das globale Grundgerüst bereit.
|
||||
- Das generelle Nexus-System wird unabhängig von den bestehenden Fachmodulen weiterentwickelt.
|
||||
|
||||
2) Umgebungen und Domains
|
||||
- Live: `nexus.kusche.berlin`
|
||||
- Staging: `staging.nexus.kusche.berlin`
|
||||
|
||||
Container- und Deploy-Layout:
|
||||
- `/app/live/` -> Live-Code
|
||||
- `/app/staging/` -> Staging-Code
|
||||
- jeweils mit eigenem `config`-Unterordner:
|
||||
- `/app/live/config/`
|
||||
- `/app/staging/config/`
|
||||
|
||||
Repo-Layout zu Configs:
|
||||
- `/config/live/` und `/config/staging/` liegen im Repo
|
||||
- beim Deployment werden die Dateien daraus nach `/app/<env>/config` kopiert
|
||||
- wichtig: im laufenden Container existiert `/app/config/` nicht, sondern nur `/app/live/config` und `/app/staging/config`
|
||||
|
||||
3) Verzeichnisstruktur
|
||||
- `/public/` -> Web Root mit globalen Assets
|
||||
- `/api/` -> Backend- und API-Endpunkte
|
||||
- `/src/` -> PHP-Kernklassen und Utilities
|
||||
- `/tools/` -> CLI, Worker und Jobs
|
||||
- `/config/` -> Umgebungs-Configs
|
||||
- `/modules/<modul>/` -> klassische Nexus-Module
|
||||
- `/partials/structure/` -> Header, Footer, Menüs
|
||||
- `/partials/landingpages/` -> komplette Seitenlayouts
|
||||
- `/debug/` -> Custom Logs
|
||||
|
||||
4) Code-Änderungen nach Scope
|
||||
- globale Layouts: `/partials/structure/` und `/public/assets/css/app.css`
|
||||
- Konfigurationslogik: nur wenn nötig `/config/` und `/src/`
|
||||
- Modul-Aufgaben: nur `/modules/<modul>/` und gegebenenfalls `/tools/<modul>`
|
||||
|
||||
5) UI-Naming und Seitenaufbau
|
||||
- `Seitenheader-Box`: oberste globale Header-Box mit Seitentitel, Login und Farbschema
|
||||
- `Submenü-Box`: Box direkt unter der Seitenheader-Box für modul- oder seitenbezogene Aktionen
|
||||
- `Submenü-Aktionen`: rechtsbündige Zusatzbuttons innerhalb der Submenü-Box, z.B. `Setup` oder `Nexus Übersicht`
|
||||
- `Bereichs-Box`: größere Inhaltsbox für einen zusammenhängenden Seitenbereich
|
||||
- `Karten-Box`: kleinere Karte auf derselben Ebene wie Bereichs-Boxen, meist in Grids
|
||||
|
||||
Zentrale CSS-Klassen:
|
||||
- `main-header-box`
|
||||
- `submenu-box`
|
||||
- `module-submenu-actions`
|
||||
- `section-box`
|
||||
- `card-box`
|
||||
|
||||
Globale Struktur:
|
||||
- zuerst Seitenheader-Box
|
||||
- danach Submenü-Box
|
||||
- danach Bereichs-Boxen und/oder Karten-Boxen je nach Seite
|
||||
|
||||
Layout-Regeln:
|
||||
- vertikale Abstände zwischen `main-header-box`, `submenu-box` und den ersten Folge-Boxen müssen aus der globalen Shell kommen
|
||||
- maßgeblich sind `module-page-bg` und `module-page-stack` in `public/assets/css/app.css`
|
||||
- Top-Level-Wrapper wie Grids, Kartencontainer oder Modul-Listen dürfen keinen zusätzlichen `margin-top` oder Sonder-Gap erzeugen, der den Abstand nach dem Submenü verändert
|
||||
- bei Layout-Reviews ist explizit zu prüfen, ob `Main-Header -> Submenü -> erste Section/Card` optisch denselben Rhythmus hat wie auf Referenzseiten
|
||||
- die Optik der Submenü-Aktionsbuttons kommt ausschließlich aus dem globalen CSS
|
||||
|
||||
Beispielstruktur:
|
||||
- Börsenchecker: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Bereichs-Box
|
||||
- FX-Rates: Seitenheader-Box, Submenü-Box, danach Bereichs-Boxen
|
||||
- Mining-Checker: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Karten-Boxen, Bereichs-Box
|
||||
- Modulverwaltung: Seitenheader-Box, Submenü-Box, danach Karten-Boxen
|
||||
|
||||
6) Nexus-Kerngerüst
|
||||
- Der aktuelle Ausbauschritt betrifft das generelle Nexus-System, nicht die bestehenden Fachmodule.
|
||||
- Bestehende Module sind funktional und strukturell zunächst ausgenommen und dürfen durch Arbeiten am Kerngerüst nicht beeinträchtigt werden.
|
||||
- Neue Kernfunktionen müssen parallel zum bestehenden Modulsystem eingeführt werden.
|
||||
- Zielbild ist ein Nexus-Grundsystem mit flexiblen Benutzer-Dashboards, Integrationen und datengetriebenen Seitenmodulen.
|
||||
|
||||
Produktprinzip:
|
||||
- Nexus ist nicht nur Modul-Launcher, sondern ein persönliches und gruppenfähiges Dashboard-System.
|
||||
- Benutzer sollen eigene Dashboards anlegen, anordnen und konfigurieren können.
|
||||
- Inhalte auf Dashboards sollen aus drei Quellen kommen können:
|
||||
- interne Nexus-Funktionen
|
||||
- externe Integrationen
|
||||
- einfache Seitenmodule ohne eigene Modul-Implementierung
|
||||
|
||||
Abgrenzung zu bestehenden Modulen:
|
||||
- das Verzeichnis `/modules/<modul>/` bleibt das Zuhause klassischer Nexus-Module
|
||||
- das Dashboard-System ist ein zusätzliches globales Kernsystem
|
||||
- neue Kernfunktionen müssen ohne Umbau bestehender Module lauffähig sein
|
||||
|
||||
7) Globale Zeitzonen-Logik
|
||||
- globale Nexus-Einstellungen liegen unter `/settings`
|
||||
- dort werden zentral gepflegt:
|
||||
- `Anzeige-Zeitzone`
|
||||
- `Standard-Zeitzone für Crons`
|
||||
|
||||
Regeln:
|
||||
- ohne Custom bei der Anzeige-Zeitzone wird die System-Zeitzone verwendet
|
||||
- die aktive Anzeige-Zeitzone soll angezeigt werden
|
||||
- die Standard-Zeitzone für Crons ist der globale Default für Modul-Crons
|
||||
- Module dürfen diese Zeitzone im Setup übersteuern
|
||||
- einzelne Cron-Einträge dürfen sie ebenfalls übersteuern
|
||||
|
||||
UTC-Speicherregel:
|
||||
- Zeitwerte sollen projektweit intern immer in `UTC` gespeichert werden
|
||||
- Anzeige-Zeitzonen dienen nur der Darstellung für Benutzer
|
||||
- Cron-Zeitzonen dienen nur der lokalen Auswertung von Zeitplänen und Fälligkeiten
|
||||
- beim Einlesen lokaler Eingaben muss vor dem Speichern nach `UTC` normalisiert werden
|
||||
- beim Anzeigen gespeicherter Werte muss von `UTC` in die jeweils wirksame Anzeige-Zeitzone umgerechnet werden
|
||||
|
||||
8) Globales Debug-System
|
||||
- das Debug-Popup ist eine globale Infrastruktur aus dem zentralen Layout
|
||||
- die Aktivierung bleibt pro Modul über `debug_enabled` im Modul-Setup steuerbar
|
||||
- das Debug-Symbol darf nur sichtbar sein, wenn für das aktuelle Modul Debug aktiv ist
|
||||
- Module sollen keine eigene separate Debug-Oberfläche bauen, wenn der globale Debug-Stream genutzt werden kann
|
||||
|
||||
9) Sicherheits- und Netzwerk-Constraints
|
||||
- Zugriff im Heimnetz `192.168.178.0/24` per Nginx begrenzt
|
||||
- SSH-Hosts nur Heimnetz
|
||||
151
temp/nexus-module-import/README-Old-Nexus.md
Normal file
151
temp/nexus-module-import/README-Old-Nexus.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Nexus
|
||||
# Comment by Lars
|
||||
|
||||
## UI-Naming
|
||||
|
||||
Für die Oberfläche gilt projektweit dieses Naming:
|
||||
|
||||
- `Seitenheader-Box`: globaler Header mit Seitentitel, Login und Farbschema
|
||||
- `Submenü-Box`: zusätzliche modul- oder seitenbezogene Aktionen direkt unter dem Seitenheader; `Setup` soll in Modulen immer vorhanden sein
|
||||
- `Submenü-Aktionen`: rechtsbündige Zusatzbuttons innerhalb der Submenü-Box, z.B. `Setup` oder `Nexus Übersicht`
|
||||
- `Bereichs-Box`: größere Inhaltsbox für einen zusammenhängenden Bereich; davon können beliebig viele untereinander folgen
|
||||
- `Karten-Box`: kleinere Inhaltskarte auf derselben Ebene wie Bereichs-Boxen, typischerweise innerhalb eines Grids für Kennzahlen, Statistiken oder Modulübersichten
|
||||
|
||||
Zentrale CSS-Klassen für dieses Layout:
|
||||
|
||||
- `main-header-box`
|
||||
- `submenu-box`
|
||||
- `module-submenu-actions`
|
||||
- `section-box`
|
||||
- `card-box`
|
||||
|
||||
Beispiele:
|
||||
|
||||
- `Börsenchecker`: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Bereichs-Box
|
||||
- `FX-Rates`: Seitenheader-Box, Submenü-Box, danach Bereichs-Boxen
|
||||
- `Mining-Checker`: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Karten-Boxen, Bereichs-Box
|
||||
- `Modulverwaltung`: Seitenheader-Box, Submenü-Box, danach Karten-Boxen
|
||||
|
||||
Technisch:
|
||||
|
||||
- globale Shell und Header in `partials/structure/` und `public/assets/css/app.css`
|
||||
- modulbezogene Inhalte und Assets ausschließlich unter `modules/<modul>/`
|
||||
- vertikale Abstände zwischen `main-header-box`, `submenu-box` und den ersten Folge-Boxen müssen aus der globalen Shell kommen; Top-Level-Grids oder Wrapper dürfen dort keinen zusätzlichen `margin-top` oder Sonderabstand einführen
|
||||
- bei Layout-Checks ist ausdrücklich zu prüfen, ob `Main-Header -> Submenü -> erste Section/Card` denselben Rhythmus hat wie auf Referenzseiten
|
||||
- die Optik von Submenü-Aktionen kommt ausschließlich aus dem globalen CSS; Module sollen dort keine eigenen Farbvarianten definieren
|
||||
|
||||
## Nexus-Kerngerüst
|
||||
|
||||
Das generelle Nexus-System wird unabhängig von den bestehenden Fachmodulen weiterentwickelt. Aktuell gilt:
|
||||
|
||||
- bestehende Module unter `modules/<modul>/` bleiben zunächst unberührt
|
||||
- neue Kernfunktionen müssen kollisionsfrei parallel zum Modulsystem aufgebaut werden
|
||||
- Zielbild ist ein flexibles Dashboard-System im Stil moderner Startseitenlösungen mit:
|
||||
- mehreren Dashboards pro Benutzer
|
||||
- frei platzierbaren Dashboard-Elementen
|
||||
- zentralen Integrationen zu Fremdsystemen
|
||||
- datengetriebenen Seitenmodulen ohne eigenen Modulordner
|
||||
|
||||
Die maßgeblichen Begriffe und Regeln dafür stehen in:
|
||||
|
||||
- `PROJECT_CONTEXT.md` als Einstieg
|
||||
- `NEXUS_SYSTEM.md`
|
||||
- `MODULE_DEVELOPMENT.md`
|
||||
- `WIDGET_INTEGRATION.md`
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Integrationen sind globale Systembausteine, keine Module
|
||||
- Seitenmodule sind on the fly konfigurierbare Inhalte ohne Code unter `modules/`
|
||||
- spätere Dashboard-Anbindungen bestehender Module sollen nur über definierte Adapter oder Provider erfolgen
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
||||
|
||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
||||
|
||||
## Add your files
|
||||
|
||||
* [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
||||
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin https://gitlab.int.kusche.berlin/private_projects/nexus.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
```
|
||||
|
||||
## Integrate with your tools
|
||||
|
||||
* [Set up project integrations](https://gitlab.int.kusche.berlin/private_projects/nexus/-/settings/integrations)
|
||||
|
||||
## Collaborate with your team
|
||||
|
||||
* [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
||||
* [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
||||
* [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
||||
* [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
||||
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
||||
|
||||
## Test and Deploy
|
||||
|
||||
Use the built-in continuous integration in GitLab.
|
||||
|
||||
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
||||
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
||||
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
||||
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
||||
* [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
||||
|
||||
***
|
||||
|
||||
# Editing this README
|
||||
|
||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
||||
|
||||
## Suggestions for a good README
|
||||
|
||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
||||
|
||||
## Name
|
||||
Choose a self-explaining name for your project.
|
||||
|
||||
## Description
|
||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
||||
|
||||
## Badges
|
||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
||||
|
||||
## Visuals
|
||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
||||
|
||||
## Installation
|
||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
||||
|
||||
## Usage
|
||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
||||
|
||||
## Support
|
||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
||||
|
||||
## Roadmap
|
||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||
|
||||
## Contributing
|
||||
State if you are open to contributions and what your requirements are for accepting them.
|
||||
|
||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
||||
|
||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
||||
|
||||
## Authors and acknowledgment
|
||||
Show your appreciation to those who have contributed to the project.
|
||||
|
||||
## License
|
||||
For open source projects, say how it is licensed.
|
||||
|
||||
## Project status
|
||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
||||
9
temp/nexus-module-import/README.md
Normal file
9
temp/nexus-module-import/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Nexus Module Import
|
||||
|
||||
Dieser Ordner ist die Rohbasis fuer die spaetere Modulmigration.
|
||||
|
||||
Regeln:
|
||||
|
||||
- Inhalte dienen als Import- und Sichtbasis.
|
||||
- Anpassungen an die neue Desktop-UI erfolgen separat im neuen Projekt.
|
||||
- Es gibt keine direkte Laufzeitkopplung aus Produktivcode auf diesen Ordner.
|
||||
@@ -0,0 +1,341 @@
|
||||
.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-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-hero-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bc-position-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
(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 = '<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)}`, { 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) {
|
||||
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;
|
||||
chartShell.innerHTML = `<div class="muted">${error.message}</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();
|
||||
})();
|
||||
995
temp/nexus-module-import/modules/boersenchecker/bootstrap.php
Normal file
995
temp/nexus-module-import/modules/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];
|
||||
});
|
||||
14
temp/nexus-module-import/modules/boersenchecker/design.json
Normal file
14
temp/nexus-module-import/modules/boersenchecker/design.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Boersenchecker",
|
||||
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
|
||||
"actions": [
|
||||
{ "label": "Nexus Übersicht", "href": "/" },
|
||||
{ "label": "Setup", "href": "/modules/setup/boersenchecker" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Ueberblick", "href": "/module/boersenchecker" },
|
||||
{ "label": "Depotverwaltung", "href": "/module/boersenchecker/depotverwaltung" },
|
||||
{ "label": "Aktienverwaltung", "href": "/module/boersenchecker/aktienverwaltung" }
|
||||
]
|
||||
}
|
||||
51
temp/nexus-module-import/modules/boersenchecker/module.json
Normal file
51
temp/nexus-module-import/modules/boersenchecker/module.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"title": "Börsenchecker",
|
||||
"version": "0.2.0",
|
||||
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
|
||||
"enabled_by_default": false,
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$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\InstrumentPage();
|
||||
module_tpl('boersenchecker', 'instruments', $page->handle());
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
$base = realpath(__DIR__ . '/../assets');
|
||||
$map = [
|
||||
'boersenchecker.css' => $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;
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$instrumentId = (int) ($_GET['instrument_id'] ?? 0);
|
||||
if ($instrumentId <= 0) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$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;
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$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\DashboardPage();
|
||||
module_tpl('boersenchecker', 'dashboard', $page->handle());
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$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());
|
||||
@@ -0,0 +1,512 @@
|
||||
<?php $ownerQuery = $isAdmin ? '?owner_sub=' . urlencode((string) $ownerSub) : ''; ?>
|
||||
<?= module_shell_header('boersenchecker', [
|
||||
'title' => 'Depotverwaltung',
|
||||
]) ?>
|
||||
<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" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<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="/module/boersenchecker/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="/modules/setup/boersenchecker">dieses Modul-Setup</a> gepflegt.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
FX-Provider, API-Key und Waehrungskatalog werden im Modul <a href="/module/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="/module/boersenchecker/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="/module/boersenchecker/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="/module/boersenchecker/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="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_position=<?= e((string) $position['id']) ?>">Bearbeiten</a>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/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="/module/boersenchecker/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>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,209 @@
|
||||
<?= module_shell_header('boersenchecker', [
|
||||
'title' => 'Depot-Ueberblick',
|
||||
]) ?>
|
||||
<div class="bc-page" data-bc-home data-chart-endpoint="<?= e($chartEndpoint) ?>">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,187 @@
|
||||
<?= module_shell_header('boersenchecker', [
|
||||
'title' => 'Aktienverwaltung',
|
||||
]) ?>
|
||||
<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" style="margin-top:16px;">
|
||||
<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="/module/boersenchecker/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>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' => '/module/boersenchecker/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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
7
temp/nexus-module-import/modules/fx-rates/api/index.php
Normal file
7
temp/nexus-module-import/modules/fx-rates/api/index.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
(new Modules\FxRates\Api\Router($service))->handle($_GET['path'] ?? '');
|
||||
@@ -0,0 +1,168 @@
|
||||
(() => {
|
||||
const root = document.getElementById('fx-rates-currencies');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = JSON.parse(root.dataset.page || '{}');
|
||||
const currencies = Array.isArray(page.currencies) ? page.currencies : [];
|
||||
const selected = new Set(
|
||||
(Array.isArray(page.preferred_currencies) ? page.preferred_currencies : [])
|
||||
.map((code) => String(code || '').trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
let displayBase = String(page.display_base_currency || '').trim().toUpperCase();
|
||||
const savedDisplayBase = String(page.saved_display_base_currency || displayBase || '').trim().toUpperCase();
|
||||
|
||||
const nodes = {
|
||||
tokenList: root.querySelector('[data-fx-token-list]'),
|
||||
searchInput: root.querySelector('[data-fx-search-input]'),
|
||||
suggestions: root.querySelector('[data-fx-suggestions]'),
|
||||
displayBaseSelect: root.querySelector('[data-fx-display-base-select]'),
|
||||
displayBaseHidden: root.querySelector('[data-fx-display-base-hidden]'),
|
||||
hiddenPreferred: root.querySelector('[data-fx-hidden-preferred]'),
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
const currencyByCode = new Map(
|
||||
currencies.map((currency) => [String(currency.code || '').toUpperCase(), currency])
|
||||
);
|
||||
|
||||
const sortedSelectedCodes = () => Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const ensureDisplayBase = () => {
|
||||
const available = sortedSelectedCodes();
|
||||
if (available.length === 0) {
|
||||
displayBase = '';
|
||||
return;
|
||||
}
|
||||
if (!displayBase || !selected.has(displayBase)) {
|
||||
displayBase = available[0];
|
||||
}
|
||||
};
|
||||
|
||||
const renderHiddenInputs = () => {
|
||||
if (!nodes.hiddenPreferred) {
|
||||
return;
|
||||
}
|
||||
nodes.hiddenPreferred.innerHTML = sortedSelectedCodes()
|
||||
.map((code) => `<input type="hidden" name="preferred_currencies[]" value="${escapeHtml(code)}">`)
|
||||
.join('');
|
||||
if (nodes.displayBaseHidden) {
|
||||
nodes.displayBaseHidden.value = displayBase;
|
||||
}
|
||||
};
|
||||
|
||||
const renderDisplayBase = () => {
|
||||
if (!nodes.displayBaseSelect) {
|
||||
return;
|
||||
}
|
||||
ensureDisplayBase();
|
||||
const available = sortedSelectedCodes();
|
||||
nodes.displayBaseSelect.innerHTML = available.length
|
||||
? available.map((code) => `<option value="${escapeHtml(code)}" ${code === displayBase ? 'selected' : ''}>${escapeHtml(code)}</option>`).join('')
|
||||
: '<option value="">Keine Waehrungen ausgewaehlt</option>';
|
||||
nodes.displayBaseSelect.disabled = available.length === 0;
|
||||
};
|
||||
|
||||
const removeCode = (code) => {
|
||||
selected.delete(code);
|
||||
renderAll();
|
||||
};
|
||||
|
||||
const renderTokens = () => {
|
||||
if (!nodes.tokenList) {
|
||||
return;
|
||||
}
|
||||
const selectedCodes = sortedSelectedCodes();
|
||||
if (selectedCodes.length === 0) {
|
||||
nodes.tokenList.innerHTML = '<div class="fx-text">Noch keine bevorzugten Waehrungen ausgewaehlt.</div>';
|
||||
return;
|
||||
}
|
||||
nodes.tokenList.innerHTML = selectedCodes.map((code) => {
|
||||
const currency = currencyByCode.get(code) || { code, name: code };
|
||||
return `
|
||||
<button type="button" class="fx-token" data-remove-code="${escapeHtml(code)}" title="${escapeHtml(code)} entfernen">
|
||||
<span>${escapeHtml(`${code} (${currency.name || code})`)}</span>
|
||||
<span class="fx-token-close">x</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
nodes.tokenList.querySelectorAll('[data-remove-code]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
removeCode(String(button.getAttribute('data-remove-code') || '').toUpperCase());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderSuggestions = () => {
|
||||
if (!nodes.suggestions || !nodes.searchInput) {
|
||||
return;
|
||||
}
|
||||
const needle = String(nodes.searchInput.value || '').trim().toLowerCase();
|
||||
if (!needle) {
|
||||
nodes.suggestions.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const matches = currencies
|
||||
.filter((currency) => !selected.has(String(currency.code || '').toUpperCase()))
|
||||
.filter((currency) => {
|
||||
const code = String(currency.code || '').toLowerCase();
|
||||
const name = String(currency.name || '').toLowerCase();
|
||||
return code.includes(needle) || name.includes(needle);
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
nodes.suggestions.innerHTML = matches.map((currency) => `
|
||||
<button type="button" class="fx-suggestion" data-add-code="${escapeHtml(String(currency.code || '').toUpperCase())}">
|
||||
<strong>${escapeHtml(String(currency.code || '').toUpperCase())}</strong>
|
||||
<span>${escapeHtml(String(currency.name || ''))}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
nodes.suggestions.querySelectorAll('[data-add-code]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const code = String(button.getAttribute('data-add-code') || '').toUpperCase();
|
||||
if (code) {
|
||||
selected.add(code);
|
||||
if (nodes.searchInput) {
|
||||
nodes.searchInput.value = '';
|
||||
}
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderAll = () => {
|
||||
ensureDisplayBase();
|
||||
renderTokens();
|
||||
renderDisplayBase();
|
||||
renderHiddenInputs();
|
||||
renderSuggestions();
|
||||
};
|
||||
|
||||
nodes.searchInput?.addEventListener('input', renderSuggestions);
|
||||
nodes.displayBaseSelect?.addEventListener('change', () => {
|
||||
displayBase = String(nodes.displayBaseSelect?.value || '').trim().toUpperCase();
|
||||
renderHiddenInputs();
|
||||
const url = new URL(window.location.href);
|
||||
if (displayBase) {
|
||||
url.searchParams.set('base', displayBase);
|
||||
} else if (savedDisplayBase) {
|
||||
url.searchParams.set('base', savedDisplayBase);
|
||||
} else {
|
||||
url.searchParams.delete('base');
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
renderAll();
|
||||
})();
|
||||
287
temp/nexus-module-import/modules/fx-rates/assets/fx-rates.css
Normal file
287
temp/nexus-module-import/modules/fx-rates/assets/fx-rates.css
Normal file
@@ -0,0 +1,287 @@
|
||||
#fx-rates-app,
|
||||
#fx-rates-currencies {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.fx-section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-section-head h2,
|
||||
.section-box h2,
|
||||
.card-box h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.fx-section-head p,
|
||||
.section-box p,
|
||||
.card-box p {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fx-form-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-form-grid label,
|
||||
.fx-block {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.fx-form-grid span,
|
||||
.fx-block span {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-form-grid input,
|
||||
.fx-form-grid select,
|
||||
.fx-block input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-message {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-message.is-error {
|
||||
color: #d92d20;
|
||||
}
|
||||
|
||||
.fx-message.is-success {
|
||||
color: color-mix(in srgb, var(--accent-green) 78%, var(--text));
|
||||
}
|
||||
|
||||
.fx-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fx-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.fx-table th,
|
||||
.fx-table td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.65rem 0.4rem;
|
||||
}
|
||||
|
||||
.fx-api-note {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fx-convert-result {
|
||||
margin-top: 1rem;
|
||||
min-height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-card-meta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fx-action-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-action-row form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fx-save-row {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fx-save-row form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fx-mini-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-card-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-mini-label,
|
||||
.fx-field-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fx-card-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fx-currency-selection-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fx-token-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fx-token-list--inline {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.fx-currency-search {
|
||||
flex: 0 1 360px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.fx-input,
|
||||
.fx-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-field {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fx-token,
|
||||
.fx-suggestion {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.7rem 1rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-token {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fx-token:hover,
|
||||
.fx-suggestion:hover {
|
||||
border-color: color-mix(in srgb, var(--brand-accent-3) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--brand-accent) 6%, var(--surface-strong));
|
||||
}
|
||||
|
||||
.fx-token-close {
|
||||
color: var(--brand-accent-3);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fx-suggestion-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.fx-suggestion {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fx-suggestion strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-text {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-history-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.fx-info-button {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-strong);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.fx-currency-selection-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fx-card-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fx-currency-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
286
temp/nexus-module-import/modules/fx-rates/assets/fx-rates.js
Normal file
286
temp/nexus-module-import/modules/fx-rates/assets/fx-rates.js
Normal file
@@ -0,0 +1,286 @@
|
||||
(() => {
|
||||
const root = document.getElementById('fx-rates-app');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = JSON.parse(root.dataset.page || '{}');
|
||||
const settings = page.settings || {};
|
||||
const nodes = {
|
||||
historyHead: root.querySelector('[data-bind="history-head"]'),
|
||||
historyBody: root.querySelector('[data-bind="history-body"]'),
|
||||
convertResult: root.querySelector('[data-bind="convert-result"]'),
|
||||
convertFrom: root.querySelector('select[name="convert_from"]'),
|
||||
convertTo: root.querySelector('select[name="convert_to"]'),
|
||||
convertAmount: root.querySelector('input[name="convert_amount"]'),
|
||||
};
|
||||
const apiBase = '/api/fx-rates/v1';
|
||||
const preferredCurrencies = Array.isArray(page.preferred_currencies)
|
||||
? page.preferred_currencies
|
||||
.map((item) => String(item || '').trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const refreshMaxAgeMinutes = Math.max(1, Number(settings.refresh_max_age_minutes || 60));
|
||||
|
||||
const parseDateValue = (value) => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = raw.replace(' ', 'T');
|
||||
normalized = normalized.replace(/([+-]\d{2})$/, '$1:00');
|
||||
const parsed = new Date(normalized);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const latestFetchedAt = () => {
|
||||
const latest = page.latest && typeof page.latest === 'object' ? page.latest : null;
|
||||
const direct = parseDateValue(latest?.fetched_at);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
|
||||
for (const entry of recentFetches) {
|
||||
const parsed = parseDateValue(entry?.fetched_at);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const bindManualRefreshAction = () => {
|
||||
const refreshLink = Array.from(document.querySelectorAll('a[href]')).find((link) => {
|
||||
try {
|
||||
const url = new URL(link.href, window.location.origin);
|
||||
return url.pathname === '/module/fx-rates' && url.searchParams.get('refresh') === '1';
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!refreshLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshLink.addEventListener('click', (event) => {
|
||||
const url = new URL(refreshLink.href, window.location.origin);
|
||||
if (url.searchParams.get('force') === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastFetch = latestFetchedAt();
|
||||
if (!lastFetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ageMinutes = (Date.now() - lastFetch.getTime()) / 60000;
|
||||
if (!Number.isFinite(ageMinutes) || ageMinutes >= refreshMaxAgeMinutes) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const confirmed = window.confirm(
|
||||
`Der letzte gespeicherte Abruf ist juenger als ${refreshMaxAgeMinutes} Minuten. ` +
|
||||
'Ein manueller Abruf wuerde die externe API trotzdem erneut aufrufen. Jetzt trotzdem abrufen?'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
url.searchParams.set('force', '1');
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
const renderHistory = (rows, currencies) => {
|
||||
if (!nodes.historyHead || !nodes.historyBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const series = Array.isArray(currencies) ? currencies : [];
|
||||
if (!series.length) {
|
||||
nodes.historyHead.innerHTML = '<tr><th>Datum</th><th>Kurse</th></tr>';
|
||||
nodes.historyBody.innerHTML = '<tr><td colspan="2">Keine bevorzugten Waehrungen fuer den Verlauf vorhanden.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.historyHead.innerHTML = `
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
${series.map((currency) => `<th>${currency}</th>`).join('')}
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const entries = Array.isArray(rows) ? rows : [];
|
||||
if (!entries.length) {
|
||||
nodes.historyBody.innerHTML = `<tr><td colspan="${series.length + 1}">Noch keine Verlaufsdaten vorhanden.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.historyBody.innerHTML = entries.map((entry) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fx-history-date">
|
||||
<span>${entry.label}</span>
|
||||
${entry.fetch ? `
|
||||
<button
|
||||
type="button"
|
||||
class="fx-info-button"
|
||||
title="${escapeHtml(`Basis: ${entry.fetch.base_currency || '-'} | Provider: ${entry.fetch.provider || '-'} | Ausloeser: ${entry.fetch.trigger_source_label || entry.fetch.trigger_source || '-'}`)}"
|
||||
aria-label="${escapeHtml(`Abrufinfo fuer ${entry.label}`)}"
|
||||
>i</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
${series.map((currency) => {
|
||||
const value = entry.rates?.[currency];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return '<td>–</td>';
|
||||
}
|
||||
return `<td>${value.toLocaleString('de-DE', { maximumFractionDigits: 8 })}</td>`;
|
||||
}).join('')}
|
||||
</tr>
|
||||
`).join('');
|
||||
};
|
||||
|
||||
const request = async (path, options = {}) => {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload.data;
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
const base = String(
|
||||
settings.display_base_currency || settings.default_base_currency || 'EUR'
|
||||
).trim().toUpperCase();
|
||||
const selectedCurrencies = preferredCurrencies.length
|
||||
? preferredCurrencies
|
||||
: [base];
|
||||
const historyCurrencies = selectedCurrencies.filter((currency) => currency !== base);
|
||||
|
||||
if (!selectedCurrencies.length) {
|
||||
renderHistory([], []);
|
||||
return;
|
||||
}
|
||||
|
||||
const histories = await Promise.all(historyCurrencies.map(async (currency) => {
|
||||
const query = new URLSearchParams({ from: base, to: currency, limit: '20' });
|
||||
const rows = await request(`/history?${query.toString()}`);
|
||||
return { currency, rows: Array.isArray(rows) ? rows : [] };
|
||||
}));
|
||||
|
||||
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
|
||||
const byDate = new Map();
|
||||
recentFetches.forEach((fetch) => {
|
||||
const key = String(fetch?.fetched_at || '').trim();
|
||||
if (!key || byDate.has(key)) {
|
||||
return;
|
||||
}
|
||||
byDate.set(key, {
|
||||
sortKey: key,
|
||||
label: fetch?.fetched_at_display || fetch?.fetched_at || key,
|
||||
fetch,
|
||||
rates: base !== '' ? { [base]: 1 } : {},
|
||||
});
|
||||
});
|
||||
|
||||
histories.forEach(({ currency, rows }) => {
|
||||
rows.forEach((row) => {
|
||||
const key = String(row?.fetched_at || row?.rate_date || '').trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
if (!byDate.has(key)) {
|
||||
byDate.set(key, {
|
||||
sortKey: key,
|
||||
label: row?.fetched_at_display || row?.fetched_at || row?.rate_date || key,
|
||||
fetch: recentFetches.find((fetch) => String(fetch?.fetched_at || '').trim() === key) || null,
|
||||
rates: base !== '' ? { [base]: 1 } : {},
|
||||
});
|
||||
}
|
||||
const entry = byDate.get(key);
|
||||
if (entry && typeof row?.rate === 'number' && Number.isFinite(row.rate)) {
|
||||
entry.rates[currency] = row.rate;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const mergedRows = Array.from(byDate.values())
|
||||
.sort((left, right) => String(right.sortKey).localeCompare(String(left.sortKey)))
|
||||
.slice(0, 15);
|
||||
|
||||
renderHistory(mergedRows, selectedCurrencies);
|
||||
};
|
||||
|
||||
const calculateConversion = async () => {
|
||||
if (!nodes.convertFrom || !nodes.convertTo || !nodes.convertAmount || !nodes.convertResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = String(nodes.convertFrom.value || '').trim().toUpperCase();
|
||||
const to = String(nodes.convertTo.value || '').trim().toUpperCase();
|
||||
const amount = Number(nodes.convertAmount.value || '0');
|
||||
|
||||
if (!from || !to || !Number.isFinite(amount)) {
|
||||
nodes.convertResult.textContent = 'Bitte Quellwaehrung, Zielwaehrung und Betrag angeben.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to}`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({ from, to });
|
||||
const data = await request(`/rate?${query.toString()}`);
|
||||
const rate = Number(data?.rate || 0);
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error('Kein Kurs verfuegbar.');
|
||||
}
|
||||
const converted = amount * rate;
|
||||
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${converted.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to} | Kurs ${rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`;
|
||||
} catch (error) {
|
||||
nodes.convertResult.textContent = error.message || 'Umrechnung konnte nicht berechnet werden.';
|
||||
}
|
||||
};
|
||||
|
||||
[nodes.convertFrom, nodes.convertTo, nodes.convertAmount].forEach((node) => {
|
||||
node?.addEventListener('change', () => {
|
||||
calculateConversion().catch(() => {});
|
||||
});
|
||||
node?.addEventListener('input', () => {
|
||||
calculateConversion().catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
bindManualRefreshAction();
|
||||
|
||||
loadHistory().catch(() => {
|
||||
renderHistory([], preferredCurrencies);
|
||||
});
|
||||
calculateConversion().catch(() => {});
|
||||
})();
|
||||
269
temp/nexus-module-import/modules/fx-rates/bootstrap.php
Normal file
269
temp/nexus-module-import/modules/fx-rates/bootstrap.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\ModuleConfigException;
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository;
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\FxRates\\';
|
||||
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 = 'fx-rates';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
|
||||
$prefix = 'fxrate_';
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return $prefix . $sanitized;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'settings', static function (): array {
|
||||
$saved = modules()->settings('fx-rates');
|
||||
$provider = trim((string) ($saved['provider'] ?? (getenv('FX_RATES_PROVIDER') ?: getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi')));
|
||||
$apiVersion = strtolower(trim((string) ($saved['api_version'] ?? 'v2')));
|
||||
$apiUrl = rtrim((string) ($saved['api_url'] ?? (getenv('FX_RATES_API_URL') ?: getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net')), '/');
|
||||
$apiKey = trim((string) ($saved['api_key'] ?? (getenv('FX_RATES_API_KEY') ?: getenv('MINING_CHECKER_FX_API_KEY') ?: '')));
|
||||
$timeout = max(2, (int) ($saved['timeout_sec'] ?? (getenv('FX_RATES_TIMEOUT') ?: getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10)));
|
||||
|
||||
$preferredCurrencies = $saved['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE'];
|
||||
if (is_string($preferredCurrencies)) {
|
||||
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
|
||||
}
|
||||
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
is_array($preferredCurrencies) ? $preferredCurrencies : []
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
|
||||
$currencyCatalog = $saved['currency_catalog'] ?? [];
|
||||
$currencyCatalog = 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($currencyCatalog) ? $currencyCatalog : [])));
|
||||
|
||||
return [
|
||||
'provider' => $provider !== '' ? $provider : 'currencyapi',
|
||||
'api_version' => in_array($apiVersion, ['v2', 'v3'], true) ? $apiVersion : 'v2',
|
||||
'api_url' => $apiUrl,
|
||||
'api_key' => $apiKey,
|
||||
'timeout_sec' => $timeout,
|
||||
'refresh_max_age_minutes' => max(1, (int) ($saved['refresh_max_age_minutes'] ?? 60)),
|
||||
'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
'display_base_currency' => strtoupper(trim((string) ($saved['display_base_currency'] ?? ($saved['default_base_currency'] ?? 'EUR')))) ?: 'EUR',
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
'currency_catalog' => $currencyCatalog,
|
||||
'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')),
|
||||
'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? nexus_cron_timezone_name())) ?: nexus_cron_timezone_name(),
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array {
|
||||
$current = modules()->settings('fx-rates');
|
||||
$normalized = module_fn('fx-rates', 'settings');
|
||||
|
||||
if (array_key_exists('default_base_currency', $payload)) {
|
||||
$normalized['default_base_currency'] = strtoupper(trim((string) $payload['default_base_currency'])) ?: $normalized['default_base_currency'];
|
||||
}
|
||||
if (array_key_exists('display_base_currency', $payload)) {
|
||||
$normalized['display_base_currency'] = strtoupper(trim((string) $payload['display_base_currency'])) ?: $normalized['display_base_currency'];
|
||||
}
|
||||
if (array_key_exists('preferred_currencies', $payload)) {
|
||||
$preferredCurrencies = $payload['preferred_currencies'];
|
||||
if (is_string($preferredCurrencies)) {
|
||||
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
|
||||
}
|
||||
$normalized['preferred_currencies'] = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
is_array($preferredCurrencies) ? $preferredCurrencies : []
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
}
|
||||
|
||||
$catalogCodes = [];
|
||||
foreach (($normalized['currency_catalog'] ?? []) as $currency) {
|
||||
if (is_array($currency)) {
|
||||
$code = strtoupper(trim((string) ($currency['code'] ?? '')));
|
||||
if ($code !== '') {
|
||||
$catalogCodes[$code] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($catalogCodes !== [] && !isset($catalogCodes[$normalized['display_base_currency']])) {
|
||||
$normalized['display_base_currency'] = $normalized['default_base_currency'];
|
||||
}
|
||||
if ($catalogCodes !== []) {
|
||||
$normalized['preferred_currencies'] = array_values(array_filter(
|
||||
$normalized['preferred_currencies'],
|
||||
static fn (string $code): bool => isset($catalogCodes[$code])
|
||||
));
|
||||
}
|
||||
|
||||
$toSave = array_merge($current, [
|
||||
'default_base_currency' => $normalized['default_base_currency'],
|
||||
'display_base_currency' => $normalized['display_base_currency'],
|
||||
'preferred_currencies' => $normalized['preferred_currencies'],
|
||||
]);
|
||||
|
||||
modules()->saveSettings('fx-rates', $toSave);
|
||||
|
||||
return module_fn('fx-rates', 'settings');
|
||||
});
|
||||
|
||||
$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, 'repository', static function (): FxRatesRepository {
|
||||
return new FxRatesRepository(module_fn('fx-rates', 'pdo'), 'fxrate_');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_schema', static function (): void {
|
||||
module_fn('fx-rates', 'repository')->ensureSchema();
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'service', static function (): FxRatesService {
|
||||
module_fn('fx-rates', 'ensure_schema');
|
||||
return new FxRatesService(
|
||||
module_fn('fx-rates', 'repository'),
|
||||
module_fn('fx-rates', 'settings')
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'setup_actions', static function (): array {
|
||||
return [
|
||||
[
|
||||
'name' => 'sync_currency_catalog',
|
||||
'label' => 'Waehrungskatalog synchronisieren',
|
||||
'help' => 'Laedt die verfuegbaren Waehrungen einmalig aus dem konfigurierten FX-Provider.',
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array {
|
||||
return match ($action) {
|
||||
'sync_currency_catalog' => (static function (): array {
|
||||
$result = module_fn('fx-rates', 'service')->refreshCurrencyCatalog();
|
||||
$current = modules()->settings('fx-rates');
|
||||
|
||||
$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];
|
||||
}
|
||||
|
||||
$latest = module_fn('fx-rates', 'service')->latestStatus();
|
||||
if (is_array($latest) && !empty($latest['id'])) {
|
||||
$snapshot = module_fn('fx-rates', 'snapshot', null, null, null, null);
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
foreach (array_keys($rates) as $code) {
|
||||
$code = strtoupper(trim((string) $code));
|
||||
if ($code !== '' && !isset($catalog[$code])) {
|
||||
$catalog[$code] = ['code' => $code, 'name' => $code];
|
||||
}
|
||||
}
|
||||
$baseCode = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
if ($baseCode !== '' && !isset($catalog[$baseCode])) {
|
||||
$catalog[$baseCode] = ['code' => $baseCode, 'name' => $baseCode];
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
$current['currency_catalog'] = array_values($catalog);
|
||||
$current['currency_catalog_synced_at'] = gmdate('Y-m-d H:i:s');
|
||||
modules()->saveSettings('fx-rates', $current);
|
||||
return $result + [
|
||||
'currencies' => array_values($catalog),
|
||||
'synced_count' => count($catalog),
|
||||
'message' => 'Waehrungskatalog synchronisiert. ' . count($catalog) . ' Waehrungen verarbeitet.',
|
||||
];
|
||||
})(),
|
||||
default => throw new \RuntimeException('Unbekannte Setup-Aktion.'),
|
||||
};
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'refresh_latest', static function (?array $currencies = null, ?string $baseCurrency = null): array {
|
||||
return module_fn('fx-rates', 'service')->refreshLatestRates($currencies, $baseCurrency);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_fresh_latest_rates', static function (float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array {
|
||||
return module_fn('fx-rates', 'service')->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'rate', static function (string $fromCurrency, string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array {
|
||||
return module_fn('fx-rates', 'service')->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'convert', static function (?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float {
|
||||
return module_fn('fx-rates', 'service')->convert($amount, $fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'snapshot', static function (?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array {
|
||||
return module_fn('fx-rates', 'service')->snapshot($baseCurrency, $at, $symbols, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'recent_fetches', static function (int $limit = 20): array {
|
||||
return module_fn('fx-rates', 'service')->recentFetches($limit);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'scheduled_refresh', static function (array $context = []): array {
|
||||
$result = module_fn('fx-rates', 'service')->runScheduledRefresh($context);
|
||||
if (function_exists('module_debug_push')) {
|
||||
module_debug_push('fx-rates', [
|
||||
'label' => 'Intervall-Aufgabe',
|
||||
'type' => 'scheduler:run',
|
||||
'task' => 'daily_refresh',
|
||||
'context' => $context,
|
||||
'message' => (string) ($result['message'] ?? ''),
|
||||
]);
|
||||
}
|
||||
return $result;
|
||||
});
|
||||
12
temp/nexus-module-import/modules/fx-rates/design.json
Normal file
12
temp/nexus-module-import/modules/fx-rates/design.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Waehrungskurse",
|
||||
"description": "Zentrale Verwaltung fuer Waehrungskurse, Snapshots und FX-API-Abrufe.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/fx-rates", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Ueberblick", "href": "/module/fx-rates" },
|
||||
{ "label": "Waehrungen", "href": "/module/fx-rates/currencies", "match_prefixes": ["/module/fx-rates/currencies"] }
|
||||
]
|
||||
}
|
||||
58
temp/nexus-module-import/modules/fx-rates/module.json
Normal file
58
temp/nexus-module-import/modules/fx-rates/module.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"title": "Waehrungskurse",
|
||||
"version": "0.1.5",
|
||||
"description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.",
|
||||
"enabled_by_default": true,
|
||||
"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": "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": "api_key", "label": "FX API Key", "type": "password", "required": false },
|
||||
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
|
||||
{ "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Blockiert neue API-Refresh-Aufrufe, solange der letzte gespeicherte Abruf juenger ist. Manuelle Abrufe koennen nach Hinweis trotzdem erzwungen werden; Cron ignoriert diesen Wert." },
|
||||
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
|
||||
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
|
||||
]
|
||||
},
|
||||
"scheduler_jobs": [
|
||||
{
|
||||
"name": "rates_refresh",
|
||||
"label": "Kursabruf",
|
||||
"callback": "scheduled_refresh",
|
||||
"mode": "multi",
|
||||
"default_enabled": true,
|
||||
"default_cron": "0 18 * * *",
|
||||
"default_timezone": "Europe/Berlin",
|
||||
"timezone_setting": "schedule_timezone",
|
||||
"lock_minutes": 120,
|
||||
"help": "Zeitgesteuerter Abruf und das Speichern neuer FX-Snapshots.",
|
||||
"builder": {
|
||||
"allow_manual": true,
|
||||
"presets": ["daily", "every_x_days", "weekly", "monthly_day", "every_x_hours"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
31
temp/nexus-module-import/modules/fx-rates/pages/asset.php
Normal file
31
temp/nexus-module-import/modules/fx-rates/pages/asset.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
$file = (string) ($_GET['file'] ?? '');
|
||||
$base = realpath(__DIR__ . '/../assets');
|
||||
$map = [
|
||||
'fx-rates.css' => $base . '/fx-rates.css',
|
||||
'fx-rates.js' => $base . '/fx-rates.js',
|
||||
'fx-rates-currencies.js' => $base . '/fx-rates-currencies.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;
|
||||
280
temp/nexus-module-import/modules/fx-rates/pages/currencies.php
Normal file
280
temp/nexus-module-import/modules/fx-rates/pages/currencies.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
|
||||
$assets->addScript('/module/fx-rates/asset?file=fx-rates-currencies.js', 'footer', true);
|
||||
}
|
||||
|
||||
$settings = module_fn('fx-rates', 'settings');
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
$notice = trim((string) ($_GET['notice'] ?? ''));
|
||||
$error = trim((string) ($_GET['error'] ?? ''));
|
||||
|
||||
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
|
||||
try {
|
||||
$action = trim((string) ($_POST['fx_action'] ?? ''));
|
||||
if ($action === 'save_selection') {
|
||||
$payload = [
|
||||
'display_base_currency' => (string) ($_POST['display_base_currency'] ?? ''),
|
||||
'preferred_currencies' => $_POST['preferred_currencies'] ?? [],
|
||||
];
|
||||
$saved = module_fn('fx-rates', 'save_runtime_settings', $payload);
|
||||
$params = ['notice' => 'Waehrungs-Auswahl gespeichert.'];
|
||||
if (is_array($saved) && !empty($saved['display_base_currency'])) {
|
||||
$params['base'] = (string) $saved['display_base_currency'];
|
||||
}
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query($params));
|
||||
}
|
||||
|
||||
if ($action === 'sync_catalog') {
|
||||
$result = module_fn('fx-rates', 'run_setup_action', 'sync_currency_catalog');
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query([
|
||||
'notice' => sprintf('Waehrungskatalog synchronisiert. %d Waehrungen verarbeitet.', (int) ($result['synced_count'] ?? 0)),
|
||||
]));
|
||||
}
|
||||
|
||||
if ($action === 'refresh_rates') {
|
||||
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query([
|
||||
'notice' => sprintf('Alle Wechselkurse aktualisiert. %d Werte gespeichert.', (int) ($result['updated_count'] ?? 0)),
|
||||
]));
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query([
|
||||
'error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Aktion konnte nicht ausgefuehrt werden.',
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
$catalog = is_array($settings['currency_catalog'] ?? null) ? $settings['currency_catalog'] : [];
|
||||
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
|
||||
$savedDisplayBaseCurrency = strtoupper(trim((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? 'EUR')));
|
||||
$requestedDisplayBaseCurrency = strtoupper(trim((string) ($_GET['base'] ?? '')));
|
||||
$latest = $service->latestStatus();
|
||||
$recentFetches = $service->recentFetches(15);
|
||||
|
||||
$currencies = [];
|
||||
foreach ($catalog as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$currencies[] = [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
$cryptoCodes = array_fill_keys([
|
||||
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
|
||||
'SOL', 'USDC', 'USDT', 'XAG', 'XAU', 'XRP',
|
||||
], true);
|
||||
$fiatCount = 0;
|
||||
$cryptoCount = 0;
|
||||
foreach ($currencies as $currency) {
|
||||
if (isset($cryptoCodes[$currency['code']])) {
|
||||
$cryptoCount++;
|
||||
} else {
|
||||
$fiatCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$catalogCodes = [];
|
||||
foreach ($currencies as $currency) {
|
||||
$catalogCodes[(string) $currency['code']] = true;
|
||||
}
|
||||
|
||||
$displayBaseCurrency = $requestedDisplayBaseCurrency !== '' ? $requestedDisplayBaseCurrency : $savedDisplayBaseCurrency;
|
||||
if ($displayBaseCurrency === '' || (!isset($catalogCodes[$displayBaseCurrency]) && $preferredCurrencies !== [])) {
|
||||
$displayBaseCurrency = $savedDisplayBaseCurrency !== '' ? $savedDisplayBaseCurrency : (string) ($preferredCurrencies[0] ?? 'EUR');
|
||||
}
|
||||
|
||||
$tableCurrencies = [];
|
||||
foreach ([$displayBaseCurrency, ...$preferredCurrencies] as $currency) {
|
||||
$currency = strtoupper(trim((string) $currency));
|
||||
if ($currency !== '' && !in_array($currency, $tableCurrencies, true)) {
|
||||
$tableCurrencies[] = $currency;
|
||||
}
|
||||
}
|
||||
|
||||
$currencyPageData = json_encode([
|
||||
'currencies' => $currencies,
|
||||
'preferred_currencies' => array_values(array_unique(array_map(static fn (mixed $code): string => strtoupper(trim((string) $code)), $preferredCurrencies))),
|
||||
'display_base_currency' => $displayBaseCurrency,
|
||||
'saved_display_base_currency' => $savedDisplayBaseCurrency,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$tabs = [
|
||||
['label' => 'Ueberblick', 'href' => '/module/fx-rates'],
|
||||
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies', 'active' => true],
|
||||
];
|
||||
?>
|
||||
<?= module_shell_header('fx-rates', [
|
||||
'title' => 'Waehrungen',
|
||||
'tabs' => $tabs,
|
||||
'actions' => [
|
||||
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
],
|
||||
]) ?>
|
||||
<div id="fx-rates-currencies" data-page='<?= e(is_string($currencyPageData) ? $currencyPageData : '{}') ?>'>
|
||||
<?php if ($notice !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-success"><?= e($notice) ?></div>
|
||||
</section>
|
||||
<?php elseif ($error !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-error"><?= e($error) ?></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-section-head">
|
||||
<div>
|
||||
<h2>Waehrungs-Update</h2>
|
||||
<p>Auswahl wird in den Waehrungskurs-Einstellungen gespeichert und steht damit auf Handy und Desktop gleich zur Verfuegung.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fx-action-row">
|
||||
<form method="post">
|
||||
<input type="hidden" name="fx_action" value="refresh_rates">
|
||||
<button type="submit" class="module-button module-button--primary">Alle Wechselkurse aktualisieren</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<input type="hidden" name="fx_action" value="sync_catalog">
|
||||
<button type="submit" class="module-button module-button--ghost">Waehrungskatalog sync</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="fx-card-grid">
|
||||
<section class="card-box">
|
||||
<div class="fx-mini-label">Fiat</div>
|
||||
<div class="fx-card-value"><?= e((string) $fiatCount) ?> Waehrungen</div>
|
||||
</section>
|
||||
<section class="card-box">
|
||||
<div class="fx-mini-label">Krypto</div>
|
||||
<div class="fx-card-value"><?= e((string) $cryptoCount) ?> Waehrungen</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-field-label">Bevorzugte Waehrungen fuer Anzeige</div>
|
||||
<div class="fx-currency-selection-row">
|
||||
<div class="fx-token-list fx-token-list--inline" data-fx-token-list></div>
|
||||
<div class="fx-currency-search">
|
||||
<input type="text" class="fx-input" value="" placeholder="Waehrung hinzufuegen: EUR, USD, DOGE oder Euro" autocomplete="off" data-fx-search-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-suggestion-list" data-fx-suggestions></div>
|
||||
|
||||
<div class="fx-field fx-currency-search">
|
||||
<label class="fx-field-label" for="fx-display-base-select">Darstellung auf Basis von</label>
|
||||
<select id="fx-display-base-select" class="fx-select" data-fx-display-base-select></select>
|
||||
</div>
|
||||
|
||||
<div class="fx-save-row">
|
||||
<form method="post">
|
||||
<input type="hidden" name="fx_action" value="save_selection">
|
||||
<input type="hidden" name="display_base_currency" value="<?= e($displayBaseCurrency) ?>" data-fx-display-base-hidden>
|
||||
<div data-fx-hidden-preferred></div>
|
||||
<button type="submit" class="module-button module-button--ghost">Auswahl speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-card-head">
|
||||
<div>
|
||||
<h2>Letzte 15 Kurs-Uploads</h2>
|
||||
<p>Zeigt die zuletzt gespeicherten Wechselkurse aus der Datenbank.</p>
|
||||
</div>
|
||||
<div class="fx-card-meta">
|
||||
<div><strong>Anzeige-Basis:</strong> <?= e($displayBaseCurrency) ?></div>
|
||||
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<?php foreach ($tableCurrencies as $currency): ?>
|
||||
<th><?= e((string) $currency) ?></th>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($recentFetches === []): ?>
|
||||
<tr><td colspan="<?= 1 + count($tableCurrencies) ?>">Noch keine Abrufe vorhanden.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recentFetches as $fetch): ?>
|
||||
<?php
|
||||
$fetchBaseCurrency = strtoupper(trim((string) ($fetch['base_currency'] ?? '')));
|
||||
$snapshot = $service->snapshotByFetchId((int) ($fetch['id'] ?? 0), $fetchBaseCurrency, $tableCurrencies);
|
||||
$originalRates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$displayBaseRate = $displayBaseCurrency === $fetchBaseCurrency
|
||||
? 1.0
|
||||
: (is_numeric($originalRates[$displayBaseCurrency] ?? null) ? (float) $originalRates[$displayBaseCurrency] : null);
|
||||
$tableRates = [];
|
||||
foreach ($tableCurrencies as $currency) {
|
||||
$currency = strtoupper(trim((string) $currency));
|
||||
if ($currency === '') {
|
||||
continue;
|
||||
}
|
||||
if ($currency === $displayBaseCurrency) {
|
||||
$tableRates[$currency] = 1.0;
|
||||
continue;
|
||||
}
|
||||
if ($displayBaseRate === null || $displayBaseRate <= 0) {
|
||||
$tableRates[$currency] = null;
|
||||
continue;
|
||||
}
|
||||
if ($currency === $fetchBaseCurrency) {
|
||||
$tableRates[$currency] = 1 / $displayBaseRate;
|
||||
continue;
|
||||
}
|
||||
$rawRate = $originalRates[$currency] ?? null;
|
||||
$tableRates[$currency] = is_numeric($rawRate) ? ((float) $rawRate / $displayBaseRate) : null;
|
||||
}
|
||||
$infoTitle = sprintf(
|
||||
'Basis: %s | Provider: %s | Ausloeser: %s',
|
||||
(string) ($fetch['base_currency'] ?? '-'),
|
||||
(string) ($fetch['provider'] ?? '-'),
|
||||
(string) ($fetch['trigger_source_label'] ?? $fetch['trigger_source'] ?? '-')
|
||||
);
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fx-history-date">
|
||||
<span><?= e((string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?></span>
|
||||
<button
|
||||
type="button"
|
||||
class="fx-info-button"
|
||||
title="<?= e($infoTitle) ?>"
|
||||
aria-label="<?= e('Abrufinfo fuer ' . (string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?>"
|
||||
>i</button>
|
||||
</div>
|
||||
</td>
|
||||
<?php foreach ($tableCurrencies as $currency): ?>
|
||||
<?php $value = $tableRates[(string) $currency] ?? null; ?>
|
||||
<td><?= is_numeric($value) ? e(number_format((float) $value, 8, ',', '')) : '–' ?></td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
149
temp/nexus-module-import/modules/fx-rates/pages/index.php
Normal file
149
temp/nexus-module-import/modules/fx-rates/pages/index.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
|
||||
$assets->addScript('/module/fx-rates/asset?file=fx-rates.js', 'footer', true);
|
||||
}
|
||||
|
||||
$settings = module_fn('fx-rates', 'settings');
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
|
||||
$apiDescribeUrl = APP_API_BASE . '/fx-rates/v1/endpoints';
|
||||
$notice = trim((string) ($_GET['notice'] ?? ''));
|
||||
$error = trim((string) ($_GET['error'] ?? ''));
|
||||
|
||||
if ((string) ($_GET['refresh'] ?? '') === '1') {
|
||||
try {
|
||||
$force = !empty($_GET['force']);
|
||||
if ($force) {
|
||||
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
|
||||
} else {
|
||||
$result = $service->autoRefreshLatestRates(
|
||||
(string) ($settings['default_base_currency'] ?? ''),
|
||||
null,
|
||||
(int) ($settings['refresh_max_age_minutes'] ?? 60),
|
||||
'manual'
|
||||
);
|
||||
}
|
||||
|
||||
$params = !empty($result['reused'])
|
||||
? [
|
||||
'notice' => sprintf(
|
||||
'Kein neuer API-Abruf. Der letzte gespeicherte Snapshot ist juenger als %d Minuten. Fuer einen erzwungenen Abruf bitte bestaetigen.',
|
||||
(int) ($settings['refresh_max_age_minutes'] ?? 60)
|
||||
),
|
||||
]
|
||||
: [
|
||||
'notice' => sprintf(
|
||||
'Aktuelle Kurse gespeichert. %d Werte aktualisiert.',
|
||||
(int) ($result['updated_count'] ?? 0)
|
||||
),
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
$params = ['error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Kurse konnten nicht aktualisiert werden.'];
|
||||
}
|
||||
|
||||
redirect('/module/fx-rates?' . http_build_query($params));
|
||||
}
|
||||
|
||||
$latest = $service->latestStatus();
|
||||
$recentFetches = $service->recentFetches(15);
|
||||
$pageData = json_encode([
|
||||
'settings' => $settings,
|
||||
'latest' => $latest,
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
'recent_fetches' => $recentFetches,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$tabs = [
|
||||
['label' => 'Ueberblick', 'href' => '/module/fx-rates', 'active' => true],
|
||||
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies'],
|
||||
];
|
||||
?>
|
||||
<?= module_shell_header('fx-rates', [
|
||||
'title' => 'Ueberblick',
|
||||
'tabs' => $tabs,
|
||||
'actions' => [
|
||||
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
['label' => 'Aktuelle Kurse abrufen', 'href' => '/module/fx-rates?refresh=1', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
],
|
||||
]) ?>
|
||||
<div id="fx-rates-app" data-page='<?= e(is_string($pageData) ? $pageData : '{}') ?>'>
|
||||
<?php if ($notice !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-success"><?= e($notice) ?></div>
|
||||
</section>
|
||||
<?php elseif ($error !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-error"><?= e($error) ?></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-section-head">
|
||||
<div>
|
||||
<h2>Umrechnung</h2>
|
||||
<p>Umrechnung auf Basis des letzten verfuegbaren Kurses zwischen den bevorzugten Waehrungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="fx-api-note">
|
||||
API-Self-Describe-Endpoint:
|
||||
<a href="<?= e($apiDescribeUrl) ?>" target="_blank" rel="noopener noreferrer"><?= e($apiDescribeUrl) ?></a>
|
||||
</p>
|
||||
<div class="fx-form-grid">
|
||||
<label>
|
||||
<span>Quellwaehrung</span>
|
||||
<select name="convert_from">
|
||||
<?php foreach ($preferredCurrencies as $currency): ?>
|
||||
<option value="<?= e((string) $currency) ?>"><?= e((string) $currency) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zielwaehrung</span>
|
||||
<select name="convert_to">
|
||||
<?php foreach ($preferredCurrencies as $currency): ?>
|
||||
<option value="<?= e((string) $currency) ?>" <?= (string) $currency === (string) ($preferredCurrencies[1] ?? $preferredCurrencies[0] ?? '') ? 'selected' : '' ?>><?= e((string) $currency) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Betrag</span>
|
||||
<input type="number" name="convert_amount" min="0" step="0.00000001" value="1">
|
||||
</label>
|
||||
</div>
|
||||
<div class="fx-convert-result" data-bind="convert-result">Noch keine Umrechnung berechnet.</div>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-card-head">
|
||||
<div>
|
||||
<h2>Kursverlauf</h2>
|
||||
<p>Neueste Abrufe zuerst. Verlauf der bevorzugten Waehrungen relativ zur Anzeige-Basiswaehrung.</p>
|
||||
</div>
|
||||
<div class="fx-card-meta">
|
||||
<div><strong>Anzeige-Basis:</strong> <?= e((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? '')) ?></div>
|
||||
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead data-bind="history-head">
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Kurse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-bind="history-body">
|
||||
<tr><td colspan="2">Noch keine Verlaufsdaten geladen.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
270
temp/nexus-module-import/modules/fx-rates/src/Api/Router.php
Normal file
270
temp/nexus-module-import/modules/fx-rates/src/Api/Router.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Api;
|
||||
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
|
||||
final class Router
|
||||
{
|
||||
public function __construct(
|
||||
private FxRatesService $service
|
||||
) {
|
||||
}
|
||||
|
||||
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 = module_fn('fx-rates', 'settings');
|
||||
$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'] : null;
|
||||
|
||||
$result = $force
|
||||
? $this->service->refreshLatestRates(null, $base, 'api')
|
||||
: $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes, '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/settings' && $method === 'GET') {
|
||||
$this->respond(['data' => module_fn('fx-rates', 'settings')]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/settings' && $method === 'PUT') {
|
||||
$this->respond(['data' => module_fn('fx-rates', 'save_runtime_settings', $this->input())]);
|
||||
}
|
||||
|
||||
$this->respond(['error' => 'Unbekannter API-Pfad.'], 404);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->respond([
|
||||
'error' => 'FX-API Fehler.',
|
||||
'context' => ['message' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
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 endpointCatalog(): array
|
||||
{
|
||||
return [
|
||||
'module' => 'fx-rates',
|
||||
'version' => 'v1',
|
||||
'languages' => ['de', 'en'],
|
||||
'endpoints' => [
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/endpoints',
|
||||
'method' => 'GET',
|
||||
'description_de' => 'Gibt alle verfuegbaren FX-API-Endpunkte mit deutscher und englischer Erklaerung zurueck.',
|
||||
'description_en' => 'Returns all available FX API endpoints with German and English explanations.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/latest',
|
||||
'method' => 'GET',
|
||||
'params' => ['base', 'symbols'],
|
||||
'description_de' => 'Liefert den neuesten gespeicherten Snapshot, optional auf eine Zielbasis umgerechnet und auf ausgewaehlte Waehrungen gefiltert.',
|
||||
'description_en' => 'Returns the latest stored snapshot, optionally rebased to a target currency and filtered to selected symbols.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/fetch',
|
||||
'method' => 'GET',
|
||||
'params' => ['fetch_id', 'base', 'symbols'],
|
||||
'description_de' => 'Liefert einen gespeicherten Snapshot anhand der fetch_id, optional umgerechnet auf eine Zielbasis und gefiltert auf einzelne Waehrungen.',
|
||||
'description_en' => 'Returns a stored snapshot by fetch_id, optionally rebased to a target currency and filtered to selected symbols.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/nearest',
|
||||
'method' => 'GET',
|
||||
'params' => ['at', 'base', 'symbols', 'window_minutes'],
|
||||
'description_de' => 'Liefert den zeitlich naechsten gespeicherten Snapshot zu einem Datum/Uhrzeit-Wert.',
|
||||
'description_en' => 'Returns the stored snapshot nearest to a given date/time value.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/snapshot',
|
||||
'method' => 'GET',
|
||||
'params' => ['at', 'base', 'symbols', 'window_minutes'],
|
||||
'description_de' => 'Liefert einen Snapshot zur Zielbasis und sucht fuer einen Zeitpunkt den naechsten passenden gespeicherten Kurs.',
|
||||
'description_en' => 'Returns a snapshot for the requested base and finds the nearest matching stored rate for a given timestamp.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/rate',
|
||||
'method' => 'GET',
|
||||
'params' => ['from', 'to', 'at', 'window_minutes'],
|
||||
'description_de' => 'Liefert einen Einzelkurs zwischen zwei Waehrungen, direkt oder als Kreuzkurs aus gespeicherten Snapshots.',
|
||||
'description_en' => 'Returns a single rate between two currencies, directly or as a cross-rate from stored snapshots.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/history',
|
||||
'method' => 'GET',
|
||||
'params' => ['from', 'to', 'from_at', 'to_at', 'limit'],
|
||||
'description_de' => 'Liefert den gespeicherten Kursverlauf zwischen zwei Waehrungen fuer einen Zeitraum.',
|
||||
'description_en' => 'Returns the stored rate history between two currencies for a given time range.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/refresh',
|
||||
'method' => 'POST',
|
||||
'body' => ['base', 'force', 'max_age_minutes'],
|
||||
'description_de' => 'Aktualisiert Kurse nur dann neu, wenn der letzte Abruf aelter als die erlaubte Zeitspanne ist. Die Antwort enthaelt immer die fetch_id des verwendeten Snapshots.',
|
||||
'description_en' => 'Refreshes rates only if the last fetch is older than the allowed age. The response always includes the fetch_id of the snapshot used.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/status',
|
||||
'method' => 'GET',
|
||||
'description_de' => 'Liefert den neuesten gespeicherten Abruf je Basiswaehrung.',
|
||||
'description_en' => 'Returns the most recent stored fetch per base currency.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/recent-fetches',
|
||||
'method' => 'GET',
|
||||
'params' => ['limit'],
|
||||
'description_de' => 'Liefert die zuletzt gespeicherten Abrufe mit fetch_id und Zeitstempel.',
|
||||
'description_en' => 'Returns the most recently stored fetches including fetch_id and timestamp.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/probe',
|
||||
'method' => 'GET',
|
||||
'params' => ['base'],
|
||||
'description_de' => 'Prueft, ob der konfigurierte Provider aktuelle Kurse liefern kann.',
|
||||
'description_en' => 'Checks whether the configured provider can return current rates.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/settings',
|
||||
'method' => 'GET',
|
||||
'description_de' => 'Liefert die aktuellen Modul-Settings.',
|
||||
'description_en' => 'Returns the current module settings.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,546 @@
|
||||
<?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)");
|
||||
} elseif ($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)
|
||||
)");
|
||||
} else {
|
||||
$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 function (array $left, array $right): int {
|
||||
return ((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' => $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);
|
||||
}
|
||||
}
|
||||
12
temp/nexus-module-import/modules/kea/design.json
Normal file
12
temp/nexus-module-import/modules/kea/design.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "KEA DHCP",
|
||||
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/kea", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Hosts", "href": "/module/kea", "match_prefixes": ["/module/kea", "/module/kea/edit"] },
|
||||
{ "label": "Gruppen", "href": "/module/kea/groups", "match_prefixes": ["/module/kea/groups"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database;
|
||||
use App\ModuleMigrationContext;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
return new class {
|
||||
public function up(ModuleMigrationContext $context): void
|
||||
{
|
||||
$settings = $context->settings();
|
||||
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||
? $context->module['metadata_db_defaults']
|
||||
: [];
|
||||
$config = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($fallback, $settings['metadata_db'])
|
||||
: $fallback;
|
||||
|
||||
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||
$repo->ensureSchema();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database;
|
||||
use App\ModuleMigrationContext;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
return new class {
|
||||
public function up(ModuleMigrationContext $context): void
|
||||
{
|
||||
$settings = $context->settings();
|
||||
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||
? $context->module['metadata_db_defaults']
|
||||
: [];
|
||||
$config = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($fallback, $settings['metadata_db'])
|
||||
: $fallback;
|
||||
|
||||
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||
$repo->ensureSchema();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database;
|
||||
use App\ModuleMigrationContext;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
return new class {
|
||||
public function up(ModuleMigrationContext $context): void
|
||||
{
|
||||
$settings = $context->settings();
|
||||
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||
? $context->module['metadata_db_defaults']
|
||||
: [];
|
||||
$config = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($fallback, $settings['metadata_db'])
|
||||
: $fallback;
|
||||
|
||||
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||
$repo->ensureSchema();
|
||||
}
|
||||
};
|
||||
46
temp/nexus-module-import/modules/kea/module.json
Normal file
46
temp/nexus-module-import/modules/kea/module.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"title": "KEA DHCP",
|
||||
"version": "1.2.0",
|
||||
"schema_version": 3,
|
||||
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "db.driver", "label": "KEA DB Driver", "type": "text", "required": true, "help": "Standard-KEA-Datenbank, die auch vom KEA-Dienst selbst genutzt wird." },
|
||||
{ "name": "db.host", "label": "KEA DB Host", "type": "text", "required": true },
|
||||
{ "name": "db.port", "label": "KEA DB Port", "type": "number", "required": true },
|
||||
{ "name": "db.dbname", "label": "KEA DB Name", "type": "text", "required": true },
|
||||
{ "name": "db.schema", "label": "KEA DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "KEA DB User", "type": "text", "required": true },
|
||||
{ "name": "db.password", "label": "KEA DB Passwort", "type": "password", "required": true },
|
||||
{ "name": "metadata_db.driver", "label": "Nexus DHCP DB Driver", "type": "text", "required": true, "help": "Separate Datenbank fuer Nexus-eigene DHCP-Zusatzinfos, nicht fuer KEA-Standardtabellen." },
|
||||
{ "name": "metadata_db.host", "label": "Nexus DHCP DB Host", "type": "text", "required": true },
|
||||
{ "name": "metadata_db.port", "label": "Nexus DHCP DB Port", "type": "number", "required": true },
|
||||
{ "name": "metadata_db.dbname", "label": "Nexus DHCP DB Name", "type": "text", "required": true },
|
||||
{ "name": "metadata_db.schema", "label": "Nexus DHCP DB Schema", "type": "text", "required": false },
|
||||
{ "name": "metadata_db.user", "label": "Nexus DHCP DB User", "type": "text", "required": true },
|
||||
{ "name": "metadata_db.password", "label": "Nexus DHCP DB Passwort", "type": "password", "required": true },
|
||||
{ "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false },
|
||||
{ "name": "kea_init_script", "label": "KEA Init Script", "type": "text", "required": false },
|
||||
{ "name": "kea_init_cmd", "label": "KEA Init Command", "type": "text", "required": false },
|
||||
{ "name": "kea_auto_init", "label": "KEA Auto-Init", "type": "checkbox", "required": false }
|
||||
]
|
||||
},
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"metadata_db_defaults": {
|
||||
"driver": "mysql",
|
||||
"host": "192.168.178.10",
|
||||
"port": 3306,
|
||||
"dbname": "",
|
||||
"schema": "",
|
||||
"user": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
91
temp/nexus-module-import/modules/kea/pages/data.php
Normal file
91
temp/nexus-module-import/modules/kea/pages/data.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
use App\Repository\KeaHostRepository;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
$settings = modules()->settings('kea');
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
|
||||
try {
|
||||
$metadataRepo = null;
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) {
|
||||
try {
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
} catch (\Throwable) {
|
||||
$metadataRepo = null;
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||
$hosts = $repo->findAll(200);
|
||||
$stats = [
|
||||
'total' => $repo->countReservations() + $repo->countLeases(),
|
||||
'reservations' => $repo->countReservations(),
|
||||
'leases' => $repo->countLeases(),
|
||||
'groups' => [],
|
||||
'free_ips' => [],
|
||||
];
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
$group = trim((string)($host['metadata']['group_name'] ?? ''));
|
||||
if ($group !== '') {
|
||||
$stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($metadataRepo !== null) {
|
||||
$stats['free_ips'] = array_map(
|
||||
static fn(array $ips): int => count($ips),
|
||||
$metadataRepo->availableIpsByGroup(
|
||||
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
|
||||
4096
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$rows = array_map(static function (array $host): array {
|
||||
return [
|
||||
'source' => (string)($host['source'] ?? 'reservation'),
|
||||
'host_id' => (string)($host['host_id'] ?? '0'),
|
||||
'display_name' => (string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt'),
|
||||
'ipv4_address' => (string)($host['ipv4_address'] ?? ''),
|
||||
'dhcp_identifier' => (string)($host['dhcp_identifier'] ?? ''),
|
||||
'last_seen_at' => (string)($host['last_seen_at'] ?? '-'),
|
||||
'lease_expires_at' => (string)($host['lease_expires_at'] ?? '-'),
|
||||
'real_name' => (string)($host['metadata']['real_name'] ?? '-'),
|
||||
'location' => (string)($host['metadata']['location'] ?? '-'),
|
||||
'group_name' => (string)($host['metadata']['group_name'] ?? '-'),
|
||||
];
|
||||
}, $hosts);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'stats' => [
|
||||
'total' => (int)$stats['total'],
|
||||
'reservations' => (int)$stats['reservations'],
|
||||
'leases' => (int)$stats['leases'],
|
||||
'groups' => count($stats['groups']),
|
||||
'free_ips' => array_sum($stats['free_ips']),
|
||||
],
|
||||
'rows' => $rows,
|
||||
'updated_at' => date('H:i:s'),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
exit;
|
||||
266
temp/nexus-module-import/modules/kea/pages/edit.php
Normal file
266
temp/nexus-module-import/modules/kea/pages/edit.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
use App\Repository\KeaHostRepository;
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$settings = modules()->settings('kea');
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
|
||||
$source = (string)($_GET['source'] ?? $_POST['source'] ?? 'reservation');
|
||||
$source = $source === 'lease' ? 'lease' : 'reservation';
|
||||
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
|
||||
$error = null;
|
||||
$notice = null;
|
||||
$host = null;
|
||||
$metadataRepo = null;
|
||||
$groups = [];
|
||||
$availableIpsByGroup = [];
|
||||
$checks = [];
|
||||
$hostKey = '';
|
||||
|
||||
try {
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) {
|
||||
throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.');
|
||||
}
|
||||
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
$groups = $metadataRepo->listGroups();
|
||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||
$host = $repo->findDisplayByKey($source, $id);
|
||||
if (!$host) {
|
||||
throw new RuntimeException('KEA Eintrag wurde nicht gefunden.');
|
||||
}
|
||||
$hostKey = $source . ':' . (string)($host['host_id'] ?? $id);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? 'save_metadata');
|
||||
if ($action === 'dns_lookup') {
|
||||
$ip = (string)($host['ipv4_address'] ?? '');
|
||||
$hostname = trim((string)($host['metadata']['device_name'] ?? $host['hostname'] ?? ''));
|
||||
$reverse = $ip !== '' ? gethostbyaddr($ip) : '';
|
||||
$forward = $hostname !== '' ? gethostbyname($hostname) : '';
|
||||
$success = ($reverse !== '' && $reverse !== $ip) || ($forward !== '' && $forward !== $hostname);
|
||||
|
||||
$metadataRepo->saveCheck($hostKey, 'dns', $success ? 'success' : 'warning', [
|
||||
'ip' => $ip,
|
||||
'hostname' => $hostname,
|
||||
'reverse' => $reverse,
|
||||
'forward' => $forward,
|
||||
]);
|
||||
$notice = $success ? 'DNS-Pruefung gespeichert.' : 'DNS-Pruefung gespeichert, aber kein eindeutiger Name gefunden.';
|
||||
} else {
|
||||
$metadata = [
|
||||
'real_name' => $_POST['real_name'] ?? '',
|
||||
'device_name' => $_POST['device_name'] ?? '',
|
||||
'owner' => $_POST['owner'] ?? '',
|
||||
'location' => $_POST['location'] ?? '',
|
||||
'device_type' => $_POST['device_type'] ?? '',
|
||||
'group_name' => $_POST['group_name'] ?? '',
|
||||
'desired_ip' => $_POST['desired_ip'] ?? '',
|
||||
'notes' => $_POST['notes'] ?? '',
|
||||
'tags' => [],
|
||||
];
|
||||
$desiredIp = trim((string)$metadata['desired_ip']);
|
||||
if ($desiredIp !== '') {
|
||||
$newHostId = $repo->reserveDisplayEntry($host, $desiredIp, $metadata);
|
||||
$source = 'reservation';
|
||||
$id = $newHostId;
|
||||
$notice = 'Zusatzdaten gespeichert und KEA-Reservierung gesetzt.';
|
||||
} else {
|
||||
$metadataRepo->saveForHost(
|
||||
$id,
|
||||
(string)($host['dhcp_identifier'] ?? ''),
|
||||
(string)($host['ipv4_address'] ?? ''),
|
||||
$metadata
|
||||
);
|
||||
$notice = 'Zusatzdaten gespeichert.';
|
||||
}
|
||||
}
|
||||
$host = $repo->findDisplayByKey($source, $id) ?: $host;
|
||||
$hostKey = $source . ':' . (string)($host['host_id'] ?? $id);
|
||||
}
|
||||
|
||||
$usedIps = array_diff(
|
||||
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
|
||||
[(string)($host['ipv4_address'] ?? ''), (string)($host['metadata']['desired_ip'] ?? '')]
|
||||
);
|
||||
$availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps);
|
||||
$checks = $metadataRepo->latestChecks([$hostKey])[$hostKey] ?? [];
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
$metadata = is_array($host['metadata'] ?? null) ? $host['metadata'] : [];
|
||||
$selectedGroup = (string)($metadata['group_name'] ?? '');
|
||||
$selectedIp = (string)($metadata['desired_ip'] ?? '');
|
||||
?>
|
||||
<?= module_shell_header('kea', [
|
||||
'title' => 'KEA Eintrag bearbeiten',
|
||||
]) ?>
|
||||
<div class="module-flow kea-page">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">KEA Eintrag bearbeiten</h2>
|
||||
<p>Zusatzdaten werden separat von der KEA-Datenbank gespeichert.</p>
|
||||
</div>
|
||||
<div class="setup-actions">
|
||||
<a class="module-button module-button--secondary" href="/module/kea/groups">Gruppen verwalten</a>
|
||||
<a class="module-button module-button--secondary" href="/module/kea">Zurueck</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="kea-message kea-message--error" role="alert">
|
||||
<strong>Fehler</strong>
|
||||
<p><?= e($error) ?></p>
|
||||
</div>
|
||||
<?php elseif ($host): ?>
|
||||
<?php if ($notice): ?>
|
||||
<div class="kea-message kea-message--success">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill"><?= ($source === 'lease') ? 'Lease' : 'Reservierung' ?></span>
|
||||
<h2 class="module-box-title"><?= e((string)($host['hostname'] ?: 'Unbekannt')) ?></h2>
|
||||
<p class="muted">
|
||||
IP <?= e((string)($host['ipv4_address'] ?? '')) ?> · MAC <?= e((string)($host['dhcp_identifier'] ?? '')) ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="kea-edit-form">
|
||||
<input type="hidden" name="action" value="save_metadata">
|
||||
<input type="hidden" name="source" value="<?= e($source) ?>">
|
||||
<input type="hidden" name="id" value="<?= e((string)$id) ?>">
|
||||
|
||||
<label class="setup-field">
|
||||
<span>Echter Name</span>
|
||||
<input type="text" name="real_name" value="<?= e((string)($metadata['real_name'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Gerätename</span>
|
||||
<input type="text" name="device_name" value="<?= e((string)($metadata['device_name'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Besitzer</span>
|
||||
<input type="text" name="owner" value="<?= e((string)($metadata['owner'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Standort</span>
|
||||
<input type="text" name="location" value="<?= e((string)($metadata['location'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Gerätetyp</span>
|
||||
<input type="text" name="device_type" value="<?= e((string)($metadata['device_type'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Gruppe</span>
|
||||
<select name="group_name" data-kea-group-select>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= e($group) ?>" <?= $selectedGroup === $group ? 'selected' : '' ?>><?= e($group) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($groups === []): ?>
|
||||
<small class="muted">Noch keine Gruppen definiert. Oeffne zuerst Gruppen verwalten.</small>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Feste IP</span>
|
||||
<select name="desired_ip" data-kea-ip-select data-selected-ip="<?= e($selectedIp) ?>">
|
||||
<option value="">Erst Gruppe waehlen</option>
|
||||
</select>
|
||||
<small class="muted">Es werden nur freie IPs aus dem IP-Bereich der gewaehlten Gruppe angeboten.</small>
|
||||
</label>
|
||||
<label class="setup-field kea-edit-form__wide">
|
||||
<span>Notizen</span>
|
||||
<textarea name="notes" rows="4"><?= e((string)($metadata['notes'] ?? '')) ?></textarea>
|
||||
</label>
|
||||
|
||||
<div class="setup-actions kea-edit-form__wide">
|
||||
<button class="cta-button" type="submit">Speichern</button>
|
||||
<a class="nav-link" href="/module/kea">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Pruefungen</span>
|
||||
<h2 class="module-box-title">Gerätechecks</h2>
|
||||
<p class="muted">Pruefergebnisse werden in der Nexus-DHCP-Datenbank gespeichert und koennen spaeter fuer Reports genutzt werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kea-check-grid">
|
||||
<div class="kea-check-card">
|
||||
<h4>DNS / Hostname</h4>
|
||||
<?php $dnsCheck = $checks['dns'] ?? null; ?>
|
||||
<?php if ($dnsCheck): ?>
|
||||
<?php $dnsResult = json_decode((string)($dnsCheck['result_json'] ?? '{}'), true) ?: []; ?>
|
||||
<p class="muted">Status: <?= e((string)($dnsCheck['status'] ?? '')) ?> · <?= e((string)($dnsCheck['checked_at'] ?? '')) ?></p>
|
||||
<p class="mono">Reverse: <?= e((string)($dnsResult['reverse'] ?? '-')) ?></p>
|
||||
<p class="mono">Forward: <?= e((string)($dnsResult['forward'] ?? '-')) ?></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">Noch keine DNS-Pruefung gespeichert.</p>
|
||||
<?php endif; ?>
|
||||
<form method="post" class="setup-actions">
|
||||
<input type="hidden" name="action" value="dns_lookup">
|
||||
<input type="hidden" name="source" value="<?= e($source) ?>">
|
||||
<input type="hidden" name="id" value="<?= e((string)$id) ?>">
|
||||
<button class="nav-link" type="submit">DNS jetzt pruefen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="kea-check-card">
|
||||
<h4>Login-Erkennung</h4>
|
||||
<p class="muted">Vorbereitet fuer spaetere HTTP/Port-Erkennung. Noch nicht automatisch aktiv, damit keine ungewollten Scans laufen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(() => {
|
||||
const ipsByGroup = <?= json_encode($availableIpsByGroup, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||
const groupSelect = document.querySelector('[data-kea-group-select]');
|
||||
const ipSelect = document.querySelector('[data-kea-ip-select]');
|
||||
if (!groupSelect || !ipSelect) {
|
||||
return;
|
||||
}
|
||||
const selectedIp = ipSelect.dataset.selectedIp || '';
|
||||
const renderIps = () => {
|
||||
const ips = ipsByGroup[groupSelect.value] || [];
|
||||
ipSelect.innerHTML = '';
|
||||
const empty = document.createElement('option');
|
||||
empty.value = '';
|
||||
empty.textContent = groupSelect.value ? 'Keine feste IP' : 'Erst Gruppe waehlen';
|
||||
ipSelect.appendChild(empty);
|
||||
if (selectedIp && !ips.includes(selectedIp)) {
|
||||
ips.unshift(selectedIp);
|
||||
}
|
||||
for (const ip of ips) {
|
||||
const option = document.createElement('option');
|
||||
option.value = ip;
|
||||
option.textContent = ip;
|
||||
option.selected = ip === selectedIp;
|
||||
ipSelect.appendChild(option);
|
||||
}
|
||||
};
|
||||
groupSelect.addEventListener('change', renderIps);
|
||||
renderIps();
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
227
temp/nexus-module-import/modules/kea/pages/groups.php
Normal file
227
temp/nexus-module-import/modules/kea/pages/groups.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
use App\Repository\KeaHostRepository;
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$settings = modules()->settings('kea');
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
$error = null;
|
||||
$notice = null;
|
||||
$groups = [];
|
||||
$availableIpsByGroup = [];
|
||||
$usedIps = [];
|
||||
|
||||
try {
|
||||
if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) {
|
||||
throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.');
|
||||
}
|
||||
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
if ($action === 'save_group') {
|
||||
$metadataRepo->saveGroup(
|
||||
(string)($_POST['name'] ?? ''),
|
||||
(string)($_POST['description'] ?? ''),
|
||||
(string)($_POST['parent_name'] ?? '')
|
||||
);
|
||||
$notice = 'Gruppe gespeichert.';
|
||||
} elseif ($action === 'add_range') {
|
||||
$metadataRepo->addRange(
|
||||
(string)($_POST['group_name'] ?? ''),
|
||||
(string)($_POST['start_ip'] ?? ''),
|
||||
(string)($_POST['end_ip'] ?? '')
|
||||
);
|
||||
$notice = 'IP-Bereich gespeichert.';
|
||||
}
|
||||
}
|
||||
|
||||
$groups = $metadataRepo->listGroupsWithRanges();
|
||||
$keaRepo = new KeaHostRepository(modules()->modulePdo('kea', $fallback), $metadataRepo);
|
||||
$usedIps = array_merge($keaRepo->usedIpAddresses(), $metadataRepo->desiredIps());
|
||||
$availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps, 4096);
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
$usedIpLookup = array_flip(array_filter(array_map('strval', $usedIps)));
|
||||
$matrixForGroup = static function (array $group) use ($usedIpLookup): array {
|
||||
$dots = [];
|
||||
foreach (($group['ranges'] ?? []) as $range) {
|
||||
$start = ip2long((string)($range['start_ip'] ?? ''));
|
||||
$end = ip2long((string)($range['end_ip'] ?? ''));
|
||||
if ($start === false || $end === false) {
|
||||
continue;
|
||||
}
|
||||
for ($ip = $start; $ip <= $end && count($dots) < 512; $ip++) {
|
||||
$address = long2ip($ip);
|
||||
if ($address === false) {
|
||||
continue;
|
||||
}
|
||||
$dots[] = [
|
||||
'ip' => $address,
|
||||
'used' => isset($usedIpLookup[$address]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $dots;
|
||||
};
|
||||
?>
|
||||
<?= module_shell_header('kea', [
|
||||
'title' => 'KEA Gruppen',
|
||||
]) ?>
|
||||
<div class="module-flow kea-page">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">KEA Gruppen</h2>
|
||||
<p>Gruppen und IP-Bereiche fuer DHCP-Reservierungen.</p>
|
||||
</div>
|
||||
<a class="module-button module-button--secondary" href="/module/kea">Zurueck</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="kea-message kea-message--error" role="alert">
|
||||
<strong>Fehler</strong>
|
||||
<p><?= e($error) ?></p>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="kea-message kea-message--success"><?= e($notice) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Gruppe</span>
|
||||
<h2 class="module-box-title">Gruppe anlegen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="kea-edit-form">
|
||||
<input type="hidden" name="action" value="save_group">
|
||||
<label class="setup-field">
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" required>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Beschreibung</span>
|
||||
<input type="text" name="description">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Uebergeordnete Gruppe</span>
|
||||
<select name="parent_name">
|
||||
<option value="">Keine</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= e((string)$group['name']) ?>"><?= e((string)$group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<div class="setup-actions kea-edit-form__wide">
|
||||
<button class="cta-button" type="submit">Gruppe speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">IP-Bereich</span>
|
||||
<h2 class="module-box-title">Bereich zuweisen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="kea-edit-form">
|
||||
<input type="hidden" name="action" value="add_range">
|
||||
<label class="setup-field">
|
||||
<span>Gruppe</span>
|
||||
<select name="group_name" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= e((string)$group['name']) ?>"><?= e((string)$group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Start-IP</span>
|
||||
<input type="text" name="start_ip" placeholder="192.168.178.50" required>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>End-IP</span>
|
||||
<input type="text" name="end_ip" placeholder="192.168.178.99" required>
|
||||
</label>
|
||||
<div class="setup-actions kea-edit-form__wide">
|
||||
<button class="cta-button" type="submit">Bereich speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="module-box-table kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Uebersicht</span>
|
||||
<h2 class="module-box-title">Gruppen und freie IPs</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kea-table-wrap">
|
||||
<table class="kea-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gruppe</th>
|
||||
<th>Untergruppe von</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Bereiche</th>
|
||||
<th>Freie IPs</th>
|
||||
<th>Matrix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($groups === []): ?>
|
||||
<tr><td colspan="6" class="kea-empty">Noch keine Gruppen definiert.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<?php $available = $availableIpsByGroup[(string)$group['name']] ?? []; ?>
|
||||
<tr>
|
||||
<td><?= e((string)$group['name']) ?></td>
|
||||
<td><?= e((string)($group['parent_name'] ?: '-')) ?></td>
|
||||
<td><?= e((string)($group['description'] ?? '-')) ?></td>
|
||||
<td>
|
||||
<?php if (($group['ranges'] ?? []) === []): ?>
|
||||
<span class="muted">Kein Bereich</span>
|
||||
<?php else: ?>
|
||||
<?php foreach ($group['ranges'] as $range): ?>
|
||||
<div class="mono"><?= e((string)$range['start_ip']) ?> - <?= e((string)$range['end_ip']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= e((string)count($available)) ?></td>
|
||||
<td>
|
||||
<?php $matrix = $matrixForGroup($group); ?>
|
||||
<?php if ($matrix === []): ?>
|
||||
<span class="muted">Kein Bereich</span>
|
||||
<?php else: ?>
|
||||
<div class="ip-matrix" aria-label="IP Matrix fuer <?= e((string)$group['name']) ?>">
|
||||
<?php foreach ($matrix as $dot): ?>
|
||||
<span
|
||||
class="ip-dot <?= $dot['used'] ? 'is-used' : 'is-free' ?>"
|
||||
title="<?= e((string)$dot['ip']) ?> · <?= $dot['used'] ? 'belegt' : 'frei' ?>"
|
||||
></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
62
temp/nexus-module-import/modules/kea/pages/index.php
Normal file
62
temp/nexus-module-import/modules/kea/pages/index.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostRepository;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
|
||||
$settings = modules()->settings('kea');
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
$metadataRepo = null;
|
||||
$hosts = [];
|
||||
$error = null;
|
||||
$warnings = [];
|
||||
$stats = [
|
||||
'total' => 0,
|
||||
'reservations' => 0,
|
||||
'leases' => 0,
|
||||
'groups' => [],
|
||||
'free_ips' => [],
|
||||
];
|
||||
|
||||
try {
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) {
|
||||
try {
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
$warnings[] = 'Nexus DHCP Zusatzdatenbank nicht verfuegbar: ' . $e->getMessage();
|
||||
$metadataRepo = null;
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||
$hosts = $repo->findAll(200);
|
||||
$stats['reservations'] = $repo->countReservations();
|
||||
$stats['leases'] = $repo->countLeases();
|
||||
$stats['total'] = $stats['reservations'] + $stats['leases'];
|
||||
foreach ($hosts as $host) {
|
||||
$group = trim((string)($host['metadata']['group_name'] ?? ''));
|
||||
if ($group !== '') {
|
||||
$stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
if ($metadataRepo !== null) {
|
||||
$stats['free_ips'] = array_map(
|
||||
static fn(array $ips): int => count($ips),
|
||||
$metadataRepo->availableIpsByGroup(
|
||||
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
|
||||
4096
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$error = "Datenbankfehler: " . $e->getMessage();
|
||||
}
|
||||
|
||||
module_tpl('kea', 'dashboard', compact('hosts', 'error', 'warnings', 'stats'));
|
||||
231
temp/nexus-module-import/modules/kea/partials/dashboard.php
Normal file
231
temp/nexus-module-import/modules/kea/partials/dashboard.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
/**
|
||||
* @var array $hosts Die Liste der KEA-Hosts.
|
||||
* @var string|null $error Eine Fehlermeldung, falls vorhanden.
|
||||
* @var array $warnings Hinweise, falls Zusatzdaten nicht geladen werden konnten.
|
||||
* @var array $stats Kennzahlen fuer die Uebersicht.
|
||||
*/
|
||||
?>
|
||||
<?= module_shell_header('kea', [
|
||||
'title' => 'KEA DHCP Hosts',
|
||||
'description' => 'Reservierungen und aktuelle Leases aus der KEA-Datenbank.',
|
||||
]) ?>
|
||||
<div class="module-flow kea-page">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">KEA DHCP Hosts</h2>
|
||||
<p>Reservierungen und aktuelle Leases aus der KEA-Datenbank.</p>
|
||||
<p class="muted kea-refresh-state" data-kea-refresh-state>
|
||||
Automatische Aktualisierung alle 5 Sekunden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="kea-message kea-message--error" role="alert">
|
||||
<strong>Fehler</strong>
|
||||
<p><?= e($error) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach (($warnings ?? []) as $warning): ?>
|
||||
<div class="kea-message kea-message--warning" role="alert">
|
||||
<p><?= e((string)$warning) ?></p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="module-box-grid module-box-grid--stats stats">
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Einträge</span>
|
||||
<span class="stat-value" data-kea-stat="total"><?= e((string)($stats['total'] ?? 0)) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Reservierungen</span>
|
||||
<span class="stat-value" data-kea-stat="reservations"><?= e((string)($stats['reservations'] ?? 0)) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Leases</span>
|
||||
<span class="stat-value" data-kea-stat="leases"><?= e((string)($stats['leases'] ?? 0)) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Gruppen</span>
|
||||
<span class="stat-value" data-kea-stat="groups"><?= e((string)count($stats['groups'] ?? [])) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Freie Gruppen-IPs</span>
|
||||
<span class="stat-value" data-kea-stat="free_ips"><?= e((string)array_sum($stats['free_ips'] ?? [])) ?></span>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="module-box-table kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Inventar</span>
|
||||
<h2 class="module-box-title">Registrierte Geräte</h2>
|
||||
<p class="muted">Zusatzdaten werden in der separaten Nexus-DHCP-Datenbank gespeichert.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kea-table-wrap">
|
||||
<table class="kea-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quelle</th>
|
||||
<th>Hostname</th>
|
||||
<th>IP Adresse</th>
|
||||
<th>MAC Adresse</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Lease bis</th>
|
||||
<th>Echter Name</th>
|
||||
<th>Standort</th>
|
||||
<th>Gruppe</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-kea-host-rows>
|
||||
<?php if (empty($hosts)): ?>
|
||||
<tr>
|
||||
<td colspan="10" class="kea-empty">
|
||||
Keine Reservierungen oder aktiven Leases gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($hosts as $host): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pill"><?= ($host['source'] ?? '') === 'lease' ? 'Lease' : 'Reservierung' ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt')) ?>
|
||||
</td>
|
||||
<td class="mono">
|
||||
<?= e($host['ipv4_address']) ?>
|
||||
</td>
|
||||
<td class="mono">
|
||||
<?= e($host['dhcp_identifier']) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['last_seen_at'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['lease_expires_at'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['real_name'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['location'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['group_name'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="nav-link" href="/module/kea/edit?source=<?= e((string)($host['source'] ?? 'reservation')) ?>&id=<?= e((string)($host['host_id'] ?? '0')) ?>">
|
||||
Bearbeiten
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const rowsTarget = document.querySelector('[data-kea-host-rows]');
|
||||
const stateTarget = document.querySelector('[data-kea-refresh-state]');
|
||||
if (!rowsTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setText = (selector, value) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (target) {
|
||||
target.textContent = String(value ?? '0');
|
||||
}
|
||||
};
|
||||
|
||||
const cell = (value, className = '') => {
|
||||
const td = document.createElement('td');
|
||||
if (className) {
|
||||
td.className = className;
|
||||
}
|
||||
td.textContent = value || '-';
|
||||
return td;
|
||||
};
|
||||
|
||||
const renderRows = (rows) => {
|
||||
rowsTarget.textContent = '';
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
const tr = document.createElement('tr');
|
||||
const td = cell('Keine Reservierungen oder aktiven Leases gefunden.', 'kea-empty');
|
||||
td.colSpan = 10;
|
||||
tr.appendChild(td);
|
||||
rowsTarget.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const source = document.createElement('td');
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'pill';
|
||||
pill.textContent = row.source === 'lease' ? 'Lease' : 'Reservierung';
|
||||
source.appendChild(pill);
|
||||
tr.appendChild(source);
|
||||
|
||||
tr.appendChild(cell(row.display_name));
|
||||
tr.appendChild(cell(row.ipv4_address, 'mono'));
|
||||
tr.appendChild(cell(row.dhcp_identifier, 'mono'));
|
||||
tr.appendChild(cell(row.last_seen_at));
|
||||
tr.appendChild(cell(row.lease_expires_at));
|
||||
tr.appendChild(cell(row.real_name));
|
||||
tr.appendChild(cell(row.location));
|
||||
tr.appendChild(cell(row.group_name));
|
||||
|
||||
const action = document.createElement('td');
|
||||
const link = document.createElement('a');
|
||||
link.className = 'nav-link';
|
||||
link.href = `/module/kea/edit?source=${encodeURIComponent(row.source || 'reservation')}&id=${encodeURIComponent(row.host_id || '0')}`;
|
||||
link.textContent = 'Bearbeiten';
|
||||
action.appendChild(link);
|
||||
tr.appendChild(action);
|
||||
|
||||
rowsTarget.appendChild(tr);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const response = await fetch('/module/kea/data', {
|
||||
headers: {Accept: 'application/json'},
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.error || 'Aktualisierung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
setText('[data-kea-stat="total"]', payload.stats?.total);
|
||||
setText('[data-kea-stat="reservations"]', payload.stats?.reservations);
|
||||
setText('[data-kea-stat="leases"]', payload.stats?.leases);
|
||||
setText('[data-kea-stat="groups"]', payload.stats?.groups);
|
||||
setText('[data-kea-stat="free_ips"]', payload.stats?.free_ips);
|
||||
renderRows(payload.rows);
|
||||
if (stateTarget) {
|
||||
stateTarget.textContent = `Automatische Aktualisierung aktiv. Zuletzt aktualisiert: ${payload.updated_at || '-'}`;
|
||||
}
|
||||
} catch (error) {
|
||||
if (stateTarget) {
|
||||
stateTarget.textContent = `Automatische Aktualisierung fehlgeschlagen: ${error.message}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.setInterval(refresh, 5000);
|
||||
})();
|
||||
</script>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($_GET['path'] ?? '');
|
||||
@@ -0,0 +1,772 @@
|
||||
#mining-checker-app {
|
||||
--mc-surface: var(--surface);
|
||||
--mc-surface-strong: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(248,252,252,0.92));
|
||||
--mc-line: var(--line);
|
||||
--mc-line-strong: color-mix(in srgb, var(--brand-accent) 28%, transparent);
|
||||
--mc-text: var(--text);
|
||||
--mc-text-muted: var(--muted);
|
||||
--mc-accent: var(--brand-accent);
|
||||
--mc-accent-strong: var(--brand-accent-3);
|
||||
--mc-danger: #d92d20;
|
||||
--mc-success: #14804a;
|
||||
--mc-warning: #b54708;
|
||||
min-height: 0;
|
||||
background: none;
|
||||
color: var(--mc-text);
|
||||
font-family: inherit;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
#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: 22px;
|
||||
background: var(--mc-surface);
|
||||
/* box-shadow: 0 12px 30px rgba(1, 22, 32, 0.08); */
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
#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-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: color-mix(in srgb, var(--mc-accent) 16%, transparent);
|
||||
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: color-mix(in srgb, var(--mc-accent) 16%, transparent);
|
||||
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: 16px;
|
||||
padding: 12px 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: linear-gradient(135deg, var(--mc-accent), var(--mc-accent-strong));
|
||||
color: #05121f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--secondary {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
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: color-mix(in srgb, var(--mc-accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--mc-accent) 34%, transparent);
|
||||
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: 18px;
|
||||
padding: 14px 16px;
|
||||
background: color-mix(in srgb, var(--brand-accent-2) 8%, var(--mc-surface));
|
||||
}
|
||||
|
||||
#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 var(--mc-line);
|
||||
border-radius: 16px;
|
||||
padding: 13px 15px;
|
||||
color: var(--mc-text);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
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: rgba(125, 211, 252, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(125, 211, 252, 0.12);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
#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(251, 113, 133, 0.28);
|
||||
background: rgba(127, 29, 29, 0.35);
|
||||
color: #ffe4e6;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--warning {
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
background: rgba(120, 53, 15, 0.34);
|
||||
color: #fef3c7;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--success {
|
||||
border-color: rgba(52, 211, 153, 0.28);
|
||||
background: rgba(6, 78, 59, 0.34);
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
#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(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table thead {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-empty {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
#mining-checker-app details {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#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: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-code-block {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
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: 28px;
|
||||
background: rgba(9, 17, 31, 0.96);
|
||||
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
3188
temp/nexus-module-import/modules/mining-checker/assets/js/app.js
Normal file
3188
temp/nexus-module-import/modules/mining-checker/assets/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
235
temp/nexus-module-import/modules/mining-checker/bootstrap.php
Normal file
235
temp/nexus-module-import/modules/mining-checker/bootstrap.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\ConnectionFactory;
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
use Modules\MiningChecker\Infrastructure\ModuleConfig;
|
||||
use Modules\MiningChecker\Infrastructure\SchemaManager;
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
$moduleName = 'mining-checker';
|
||||
|
||||
$mm->registerFunction($moduleName, 'runtime_settings', static function (): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$projectKey = $config->defaultProjectKey();
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
|
||||
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
|
||||
|
||||
$settings = $repository->getSettings($projectKey) ?? [];
|
||||
$displayTimezone = trim((string) ($settings['display_timezone'] ?? nexus_display_timezone_name()));
|
||||
if ($displayTimezone === '') {
|
||||
$displayTimezone = nexus_display_timezone_name();
|
||||
}
|
||||
|
||||
$baselineMeasuredAt = trim((string) ($settings['baseline_measured_at'] ?? ''));
|
||||
if ($baselineMeasuredAt !== '') {
|
||||
try {
|
||||
$baselineMeasuredAt = (new DateTimeImmutable($baselineMeasuredAt, new DateTimeZone('UTC')))
|
||||
->setTimezone(new DateTimeZone($displayTimezone))
|
||||
->format('Y-m-d\TH:i');
|
||||
} catch (\Throwable) {
|
||||
$baselineMeasuredAt = '';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'baseline_measured_at' => $baselineMeasuredAt,
|
||||
'baseline_coins_total' => isset($settings['baseline_coins_total']) ? (string) $settings['baseline_coins_total'] : '',
|
||||
'report_currency' => strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$projectKey = $config->defaultProjectKey();
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
|
||||
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
|
||||
$schema->ensureSchema();
|
||||
|
||||
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
|
||||
$repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey)));
|
||||
|
||||
$existing = $repository->getSettings($projectKey) ?? [];
|
||||
$displayTimezone = trim((string) ($existing['display_timezone'] ?? nexus_display_timezone_name()));
|
||||
if ($displayTimezone === '') {
|
||||
$displayTimezone = nexus_display_timezone_name();
|
||||
}
|
||||
|
||||
try {
|
||||
$displayTz = new DateTimeZone($displayTimezone);
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('Ungueltige Anzeige-Zeitzone.');
|
||||
}
|
||||
|
||||
$baselineInput = trim((string) ($payload['baseline_measured_at'] ?? ($existing['baseline_measured_at'] ?? '')));
|
||||
if ($baselineInput === '') {
|
||||
throw new RuntimeException('Baseline Zeitpunkt fehlt.');
|
||||
}
|
||||
|
||||
try {
|
||||
$baselineUtc = (new DateTimeImmutable($baselineInput, $displayTz))
|
||||
->setTimezone(new DateTimeZone('UTC'))
|
||||
->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('Baseline Zeitpunkt ist ungueltig.');
|
||||
}
|
||||
|
||||
$baselineCoins = trim((string) ($payload['baseline_coins_total'] ?? ($existing['baseline_coins_total'] ?? '')));
|
||||
if ($baselineCoins === '' || !is_numeric($baselineCoins)) {
|
||||
throw new RuntimeException('Baseline Coins muessen numerisch sein.');
|
||||
}
|
||||
|
||||
$preferredCurrencies = $existing['preferred_currencies'] ?? ['DOGE', 'USD', 'EUR'];
|
||||
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 : []
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
|
||||
$settings = [
|
||||
'baseline_measured_at' => $baselineUtc,
|
||||
'baseline_coins_total' => (float) $baselineCoins,
|
||||
'daily_cost_amount' => isset($existing['daily_cost_amount']) && is_numeric((string) $existing['daily_cost_amount'])
|
||||
? (float) $existing['daily_cost_amount']
|
||||
: 0.0,
|
||||
'daily_cost_currency' => strtoupper(trim((string) ($existing['daily_cost_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
'report_currency' => strtoupper(trim((string) ($payload['report_currency'] ?? ($existing['report_currency'] ?? 'EUR')))) ?: 'EUR',
|
||||
'crypto_currency' => strtoupper(trim((string) ($existing['crypto_currency'] ?? 'DOGE'))) ?: 'DOGE',
|
||||
'display_timezone' => $displayTimezone,
|
||||
'fx_max_age_hours' => isset($existing['fx_max_age_hours']) && is_numeric((string) $existing['fx_max_age_hours'])
|
||||
? (int) $existing['fx_max_age_hours']
|
||||
: 3,
|
||||
'module_theme_mode' => in_array((string) ($existing['module_theme_mode'] ?? 'inherit'), ['inherit', 'custom'], true)
|
||||
? (string) $existing['module_theme_mode']
|
||||
: 'inherit',
|
||||
'module_theme_accent' => in_array((string) ($existing['module_theme_accent'] ?? 'teal'), ['teal', 'logo', 'pink', 'cyan', 'orange', 'green'], true)
|
||||
? (string) $existing['module_theme_accent']
|
||||
: 'teal',
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
];
|
||||
|
||||
$repository->saveSettings($projectKey, $settings);
|
||||
|
||||
return module_fn('mining-checker', 'runtime_settings');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'setup_actions', static function (): array {
|
||||
return [
|
||||
[
|
||||
'name' => 'initialize_schema',
|
||||
'label' => 'Tabellen importieren',
|
||||
'section' => 'database',
|
||||
'help' => 'Legt die Mining-Checker Tabellen an, wenn sie noch nicht vorhanden sind.',
|
||||
],
|
||||
[
|
||||
'name' => 'upgrade_schema',
|
||||
'label' => 'Tabellen updaten',
|
||||
'section' => 'database',
|
||||
'help' => 'Fuehrt fehlende Tabellen- und Spalten-Upgrades fuer den Mining-Checker aus.',
|
||||
],
|
||||
[
|
||||
'name' => 'seed_import',
|
||||
'label' => 'Seed-Daten importieren',
|
||||
'section' => 'database',
|
||||
'help' => 'Importiert die vordefinierten Startdaten fuer das Standardprojekt.',
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'setup_status', static function (): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
|
||||
$status = $schema->schemaStatus();
|
||||
|
||||
return [
|
||||
'title' => 'Tabellenstatus',
|
||||
'type' => !empty($status['all_present']) && empty($status['pending_upgrades']) ? 'success' : 'hint',
|
||||
'text' => !empty($status['all_present'])
|
||||
? (empty($status['pending_upgrades'])
|
||||
? 'Alle Mining-Checker Tabellen sind vorhanden.'
|
||||
: 'Alle Grundtabellen sind vorhanden, aber es gibt noch ausstehende Upgrades.')
|
||||
: 'Das Mining-Checker Schema ist noch unvollstaendig.',
|
||||
'stats' => [
|
||||
['label' => 'Vorhandene Tabellen', 'value' => (string) ((int) ($status['present_count'] ?? 0) . '/' . count((array) ($status['required_tables'] ?? [])))],
|
||||
['label' => 'Fehlende Tabellen', 'value' => !empty($status['missing_tables']) ? implode(', ', (array) $status['missing_tables']) : 'keine'],
|
||||
['label' => 'Ausstehende Upgrades', 'value' => !empty($status['pending_upgrades']) ? implode(', ', (array) $status['pending_upgrades']) : 'keine'],
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$projectKey = $config->defaultProjectKey();
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
|
||||
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
|
||||
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
|
||||
|
||||
return match ($action) {
|
||||
'initialize_schema' => (static function () use ($schema): array {
|
||||
$result = $schema->initializeSchema(false);
|
||||
$after = is_array($result['after'] ?? null) ? $result['after'] : [];
|
||||
return [
|
||||
'message' => ($result['message'] ?? 'Schema initialisiert.')
|
||||
. ' Vorhanden: ' . (int) ($after['present_count'] ?? 0)
|
||||
. '/' . count((array) ($after['required_tables'] ?? [])) . '.',
|
||||
];
|
||||
})(),
|
||||
'upgrade_schema' => (static function () use ($schema): array {
|
||||
$result = $schema->upgradeSchemaDirect();
|
||||
$applied = array_values(array_filter(array_map('strval', (array) ($result['upgraded'] ?? []))));
|
||||
return [
|
||||
'message' => ($result['message'] ?? 'Schema-Upgrade ausgefuehrt.')
|
||||
. ($applied !== [] ? ' Upgrades: ' . implode(', ', $applied) . '.' : ''),
|
||||
];
|
||||
})(),
|
||||
'seed_import' => (static function () use ($schema, $repository, $projectKey): array {
|
||||
$schema->ensureSchema();
|
||||
$repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey)));
|
||||
$result = (new \Modules\MiningChecker\Domain\SeedImporter($repository))->import($projectKey);
|
||||
return [
|
||||
'message' => 'Seed-Daten importiert. Eingefuegt: ' . (int) ($result['inserted'] ?? 0) . '.',
|
||||
];
|
||||
})(),
|
||||
default => throw new RuntimeException('Unbekannte Setup-Aktion.'),
|
||||
};
|
||||
});
|
||||
|
||||
$mm->registerFunction('mining-checker', 'sql_import_target', static function (): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
|
||||
return [
|
||||
'pdo' => ConnectionFactory::make($config),
|
||||
'label' => 'Mining-Checker Projekt-Datenbank',
|
||||
];
|
||||
});
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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
temp/nexus-module-import/modules/mining-checker/design.json
Normal file
17
temp/nexus-module-import/modules/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": "/modules/setup/mining-checker", "variant": "secondary" }
|
||||
],
|
||||
"sections": [
|
||||
{ "key": "overview", "label": "Übersicht" },
|
||||
{ "key": "upload", "label": "Upload" },
|
||||
{ "key": "measurements", "label": "Mining-History" },
|
||||
{ "key": "wallet", "label": "Wallet" },
|
||||
{ "key": "mining", "label": "Miner-Daten" },
|
||||
{ "key": "dashboards", "label": "Dashboards" }
|
||||
]
|
||||
}
|
||||
114
temp/nexus-module-import/modules/mining-checker/docs/README.md
Normal file
114
temp/nexus-module-import/modules/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
|
||||
modules/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.
|
||||
30
temp/nexus-module-import/modules/mining-checker/module.json
Normal file
30
temp/nexus-module-import/modules/mining-checker/module.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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,
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
|
||||
$design = module_design('mining-checker');
|
||||
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
|
||||
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
|
||||
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
$allowedViews = [];
|
||||
$tabs = [];
|
||||
foreach ($sections as $section) {
|
||||
if (!is_array($section)) {
|
||||
continue;
|
||||
}
|
||||
$key = trim((string) ($section['key'] ?? ''));
|
||||
$label = trim((string) ($section['label'] ?? ''));
|
||||
if ($key === '' || $label === '') {
|
||||
continue;
|
||||
}
|
||||
$allowedViews[] = $key;
|
||||
$tabs[] = [
|
||||
'label' => $label,
|
||||
'href' => '/module/mining-checker?view=' . rawurlencode($key),
|
||||
'active' => $activeView === $key,
|
||||
];
|
||||
}
|
||||
if ($allowedViews === []) {
|
||||
$allowedViews = ['overview'];
|
||||
}
|
||||
if (!in_array($activeView, $allowedViews, true)) {
|
||||
$activeView = $allowedViews[0];
|
||||
foreach ($tabs as &$tab) {
|
||||
$tab['active'] = str_ends_with((string) ($tab['href'] ?? ''), 'view=' . $activeView);
|
||||
}
|
||||
unset($tab);
|
||||
}
|
||||
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$moduleCss = file_get_contents(dirname(__DIR__) . '/assets/css/app.css') ?: '';
|
||||
$moduleJs = file_get_contents(dirname(__DIR__) . '/assets/js/app.js') ?: '';
|
||||
$moduleJs = str_replace('</script>', '<\/script>', $moduleJs);
|
||||
?>
|
||||
<?= module_shell_header('mining-checker', [
|
||||
'title' => 'DOGE Mining-Checker',
|
||||
'tabs' => $tabs,
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<div id="mining-checker-app"
|
||||
data-default-project-key="<?= e($defaultProjectKey) ?>"
|
||||
data-api-base="/api/mining-checker/v1"
|
||||
data-active-view="<?= e($activeView) ?>"
|
||||
data-sections-json="<?= e(is_string($sectionsJson) ? $sectionsJson : '[]') ?>"></div>
|
||||
</div>
|
||||
<style><?= $moduleCss ?></style>
|
||||
<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><?= $moduleJs ?></script>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -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;
|
||||
@@ -0,0 +1,223 @@
|
||||
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_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
|
||||
);
|
||||
@@ -0,0 +1,243 @@
|
||||
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_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);
|
||||
223
temp/nexus-module-import/modules/mining-checker/sql/schema.sql
Normal file
223
temp/nexus-module-import/modules/mining-checker/sql/schema.sql
Normal file
@@ -0,0 +1,223 @@
|
||||
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_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
temp/nexus-module-import/modules/mining-checker/sql/seed.sql
Normal file
75
temp/nexus-module-import/modules/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);
|
||||
2774
temp/nexus-module-import/modules/mining-checker/src/Api/Router.php
Normal file
2774
temp/nexus-module-import/modules/mining-checker/src/Api/Router.php
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,950 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
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;
|
||||
|
||||
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 (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
return is_object($service) ? $service : null;
|
||||
} catch (\Throwable) {
|
||||
return 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
<?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);
|
||||
$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
|
||||
{
|
||||
$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) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
use App\Database as AppDatabase;
|
||||
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);
|
||||
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||
return AppDatabase::connectFromConfig($dbConfig);
|
||||
}
|
||||
return AppDatabase::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
$dbConfig = app()->config()->dbConfig;
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
|
||||
}
|
||||
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
|
||||
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||
return AppDatabase::connectFromConfig($dbConfig);
|
||||
}
|
||||
|
||||
return AppDatabase::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']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user